diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 75% rename from .github/ISSUE_TEMPLATE/02-new-feature.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 500beea4..5d1b4346 100644 --- a/.github/ISSUE_TEMPLATE/02-new-feature.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,6 +1,4 @@ -name: "Feature Request" -description: Request a new feature -title: "[FEA] - " +title: "<title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index f67a7db7..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: "[BUG] - <title>" labels: [ "bug" ] @@ -23,12 +22,22 @@ 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: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -36,7 +45,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 2dc73ff6..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: "[BUG] - <title>" labels: [ "bug", "playback" ] @@ -12,11 +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) + label: Experimental settings + description: Do you have experiment settings turn on? + options: + - "Yes" + - "No" validations: required: true - type: dropdown @@ -28,6 +30,15 @@ body: options: - ExoPlayer (default) - MPV + - 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 @@ -35,7 +46,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -43,7 +54,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml deleted file mode 100644 index bd10bd46..00000000 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Question" -description: Ask a question -title: "[QST] - <title>" -labels: [ - "question" -] -body: - - type: textarea - id: description - attributes: - label: "Question" - description: Please enter your question - placeholder: "How do I...?" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..2ee91c39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: New feature / UI suggestion + url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas + about: Please request new features here + - name: Questions + url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a + about: Please ask questions here diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml deleted file mode 100644 index 770e552a..00000000 --- a/.github/actions/native-build/action.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: "Native build" -description: "Performs native builds" -inputs: - cache: - description: "Whether to use the cache or not" - required: true -runs: - using: "composite" - steps: - - name: Load ffmpeg module cache - id: cache_ffmpeg_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v5 - with: - path: | - app/libs - key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} - - name: Load libmpv module cache - id: cache_libmpv_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v5 - with: - path: | - app/src/main/libs - key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} - - name: Install dependencies - if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - python -m pip install --upgrade pip - pip install jsonschema jinja2 - sudo apt update - sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm - - -# ffmpeg - - name: Build ffmpeg decoder - id: ffmpeg-decoder - if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/ffmpeg - ./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}" - - name: Save ffmpeg module cache - id: cache_ffmpeg_module_save - uses: actions/cache/save@v5 - with: - path: | - app/libs - key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} - -# libmpv - - name: Get libmpv dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/mpv - ./get_dependencies.sh - - name: Build libmpv dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - cd scripts/mpv - ./buildall.sh --clean --arch arm64 mpv - ./buildall.sh mpv - cd ../.. - env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j - - - name: Setup jniLibs - shell: bash - run: | - cd app/src/main - mkdir -p jniLibs - cp -r libs/* jniLibs - #ln -s libs jniLibs - - name: Save libmpv module cache - id: cache_libmpv_module_save - uses: actions/cache/save@v5 - with: - path: | - app/src/main/libs - key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }} diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 774dae38..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@v3 + 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 9583525d..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='-Xmx4g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" jobs: build: @@ -25,15 +25,11 @@ 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 uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: true - name: Get version names run: | @@ -55,8 +51,12 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleRelease assembleDebug --no-daemon + ./gradlew clean assembleDefaultDebug + ./gradlew assembleDefaultRelease + - name: Verify signatures run: | echo "Verify APK signatures" @@ -68,20 +68,27 @@ jobs: find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" + short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')" echo "$line => $short_name" cp "$line" "$short_name" done - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') - echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Checksums 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 }} run: | + shopt -s globstar + gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true @@ -98,4 +105,4 @@ jobs: gh release create "${{ env.TAG_NAME }}" \ --latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \ - "app/build/outputs/apk/**/*.apk" + app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..e423cccf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,54 +9,36 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -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 uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: true - name: Build app id: buildapp run: | - ./gradlew clean assembleDebug testDebugUnitTest --no-daemon - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon + apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - name: Tar build dirs - run: | - tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v6 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v6 - with: - name: APKs - path: "${{ steps.buildapp.outputs.apks }}" - compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..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='-Xmx4g -XX:MaxMetaspaceSize=512m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" TAG_NAME: ${{ github.ref_name }} jobs: @@ -21,15 +21,11 @@ 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 uses: ./.github/actions/setup - - name: Native build - uses: ./.github/actions/native-build - with: - cache: false - name: Build app id: buildapp env: @@ -37,33 +33,51 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" + ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleRelease --no-daemon + ./gradlew clean bundleAppstoreRelease + ./gradlew bundleFiretvRelease + ./gradlew assembleDefaultRelease - name: Verify signatures run: | echo "Verify APK signatures" - find app/build/outputs/apk -name '*.apk' - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do - # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" + # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk + short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" echo "$line => $short_name" cp "$line" "$short_name" done - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') - echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum + - name: Upload AAB + 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 }} run: | + shopt -s globstar + gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ - "app/build/outputs/apk/**/*.apk" + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ + app/build/outputs/apk/**/*.apk diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6856ebb2..0d818e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,6 +14,7 @@ The app uses: * [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage * [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection * [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback +* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback * [Coil](https://coil-kt.github.io/coil/) for image loading * [OkHttp](https://square.github.io/okhttp/) for HTTP requests @@ -33,6 +34,16 @@ Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: ht Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues. +#### Extensions + +Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work. + +If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository. + +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: @@ -42,29 +53,22 @@ Code is split into several packages: - `ui` - User interface code and ViewModels - `util` - Utility classes and functions -### Native components +### Settings -#### FFmpeg decoder module +There are a few different ways user settings are stored: +1. `AppPreferences` via DataStore +2. Room database +3. Key-Value DataStore -Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md). +#### App preferences -It is not required to build the extension in order to build the app locally. +These are generally settings that apply across the whole app regardless of the currently active user. -You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script. +The `AppPreferences` object can be retrieved from the `UserPreferencesService` or directly via injecting `DataStore<AppPreferences>`. -#### MPV player backend +The `AppPreference` (note the `s` differences) objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`. -Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`. - -See the [build scripts](scripts/mpv/) for details on building this component. - -### App settings - -App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences). - -The `AppPreference` objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`. - -#### How to add a new app setting +##### How to add a new app setting 1. Add entry in `WholphinDataStore.proto` & build to generate classes 2. Add new `AppPreference` object in `AppPreference.kt` @@ -72,3 +76,30 @@ The `AppPreference` objects are used to create the UI for configuring settings u 4. Update `AppPreferencesSerializer` to set the default value for new installs 5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades - Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different + +#### Room settings + +These are settings were generally are applied per user. They are stored in several different tables. The entities are defined in `com.github.damontecres.wholphin.data.model`. + +Additionally, all server and user info for both Jellyfin and Seerr are stored in Room tables. + +Some examples are: +- `JellyfinServer` +- `JellyfinUser` +- `SeerrServer` +- `LibraryDisplayInfo` +- `ItemPlayback` + +Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSON in a column instead of defined entities. This should be used sparingly. + +#### Key-value settings + +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 b4121cb9..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." @@ -24,39 +36,49 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin </p> -<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> +![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) ## Features ### User interface +- 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](https://github.com/seerr-team/seerr) to discover new movies and TV shows -- Option to combine Continue Watching & Next Up rows -- Show Movie/TV Show titles when browsing libraries +- 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) +- 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 See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) @@ -93,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 @@ -112,14 +134,21 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ## Additional screenshots +### Customized home page +![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) + ### Movie library browsing -<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> +![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) ### Movie page -<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> +![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) ### Series page -<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> +![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) + +### Genres in library +![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) + ### Playlist -<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> +![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29c36ac2..98f03a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,13 +1,17 @@ +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 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 plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) @@ -18,10 +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 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 @@ -35,54 +56,37 @@ val gitDescribe = .standardOutput.asText .getOrElse("v0.0.0") -android { +kotlin { + compilerOptions { + 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 = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" + + buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) + buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") } - buildTypes { - release { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - isDebuggable = false - } - debug { - isMinifyEnabled = false - isDebuggable = true - applicationIdSuffix = ".debug" - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - isCoreLibraryDesugaringEnabled = true - } - kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_11 - javaParameters = true - } - } - buildFeatures { - buildConfig = true - compose = true - } - room { - schemaDirectory("$projectDir/schemas") - } signingConfigs { - if (shouldSign) { + if (shouldSign.get()) { create("ci") { file("ci.keystore").writeBytes( Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), @@ -98,15 +102,17 @@ android { } } } + buildTypes { release { - isMinifyEnabled = false - + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - if (shouldSign) { + isDebuggable = false + if (shouldSign.get()) { signingConfig = signingConfigs.getByName("ci") } else { val localPropertiesFile = project.rootProject.file("local.properties") @@ -120,39 +126,170 @@ android { } } } + debug { - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } + isMinifyEnabled = false + isShrinkResources = false + isDebuggable = true + applicationIdSuffix = ".debug" } - applicationVariants.all { - val variant = this - variant.outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } - val outputFileName = - "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" - output.outputFileName = outputFileName - } + create("optimized") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".opt" + matchingFallbacks += "release" } } + flavorDimensions += "version" + productFlavors { + val featureLeanback = "leanback" + val featureUpdate = "UPDATING_ENABLED" + val featureDiscover = "DISCOVER_ENABLED" + + fun ProductFlavor.setFeatureFlag( + name: String, + enabled: Boolean, + ) { + this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")") + } + create("default") { + dimension = "version" + isDefault = true + manifestPlaceholders += mapOf(featureLeanback to false) + setFeatureFlag(featureUpdate, true) + setFeatureFlag(featureDiscover, true) + } + create("appstore") { + dimension = "version" + manifestPlaceholders += mapOf(featureLeanback to true) + setFeatureFlag(featureUpdate, false) + setFeatureFlag(featureDiscover, true) + } + create("firetv") { + dimension = "version" + manifestPlaceholders += mapOf(featureLeanback to true) + setFeatureFlag(featureUpdate, false) + setFeatureFlag(featureDiscover, false) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + + buildFeatures { + buildConfig = true + compose = true + } 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 } } + packaging { + jniLibs { + // Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so + pickFirsts += "lib/*/libc++_shared.so" + } + } sourceSets { getByName("main") { - kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + 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" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + lint { + disable.add("MissingTranslation") + } + androidResources { + generateLocaleConfig = true + } +} + +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs + .map { it as com.android.build.api.variant.impl.VariantOutputImpl } + .forEach { output -> + val abi = + output + .getFilter(FilterConfiguration.FilterType.ABI) + .let { if (it != null) "-${it.identifier}" else "" } + val outputFileName = + "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) } + } + } +} + +room { + schemaDirectory("$projectDir/schemas") } protobuf { @@ -212,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)) @@ -219,17 +358,17 @@ 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) implementation(libs.protobuf.kotlin.lite) 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) @@ -238,6 +377,8 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) 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) @@ -261,12 +402,12 @@ 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) implementation(libs.androidx.palette.ktx) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -288,21 +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 || isCI) { + + if (ffmpegModuleExists.get()) { + logger.info("Using local ffmpeg decoder") implementation(files("libs/lib-decoder-ffmpeg-release.aar")) + } 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 || isCI) { + if (av1ModuleExists.get()) { + logger.info("Using local av1 decoder") implementation(files("libs/lib-decoder-av1-release.aar")) + } 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.get()) { + logger.info("Using local libMPV build") + implementation(files("libs/wholphin-mpv-release.aar")) + } else if (extensionsRepoActive.get()) { + logger.info("Using prebuilt libMPV") + implementation(libs.wholphin.extensions.mpv) + } else { + 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.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/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json new file mode 100644 index 00000000..b719e5f4 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "c6829d764ec85321ab3be9905d6c0e3a", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` 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" + } + ], + "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, 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" + } + ], + "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, 'c6829d764ec85321ab3be9905d6c0e3a')" + ] + } +} 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/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..09dc6745 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,78 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +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 androidx.navigation3.runtime.NavBackStack +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + val navigationManager = NavigationManager() + navigationManager.backStack = NavBackStack(Destination.Home()) + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = navigationManager, + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml new file mode 100644 index 00000000..e024ca83 --- /dev/null +++ b/app/src/appstore/AndroidManifest.xml @@ -0,0 +1,10 @@ +<?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" /> + <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 161352af..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,13 +15,14 @@ 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" android:required="false" /> <uses-feature android:name="android.software.leanback" - android:required="false" /> + android:required="${leanback}" /> <uses-feature android:name="android.hardware.microphone" android:required="false" /> @@ -30,6 +32,10 @@ <intent> <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> </intent> + <intent> + <action android:name="android.intent.action.VIEW" /> + <data android:mimeType="video/*" /> + </intent> </queries> <application @@ -41,18 +47,27 @@ android:theme="@style/Theme.Wholphin" android:name=".WholphinApplication" android:usesCleartextTraffic="true" - android:networkSecurityConfig="@xml/network_security_config"> + android:networkSecurityConfig="@xml/network_security_config" + android:largeHeap="true"> <activity 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 @@ -67,7 +82,32 @@ <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" + android:exported="true" + android:label="@string/app_name" + android:permission="android.permission.BIND_DREAM_SERVICE"> + <intent-filter> + <action android:name="android.service.dreams.DreamService" /> + <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 dcc25af0..ec61583d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,17 +1,19 @@ package com.github.damontecres.wholphin +import android.content.Context import android.content.Intent 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 import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -19,76 +21,85 @@ 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.graphics.RectangleShape -import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay +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.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler 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.launchIO -import com.github.damontecres.wholphin.ui.nav.ApplicationContent +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.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.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 kotlinx.coroutines.Dispatchers +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +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() { private val viewModel: MainActivityViewModel by viewModels() + private val playExternalViewModel: PlayExternalViewModel by viewModels() @Inject lateinit var userPreferencesDataStore: DataStore<AppPreferences> + @Inject + lateinit var userPreferencesService: UserPreferencesService + @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -102,15 +113,15 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var updateChecker: UpdateChecker - @Inject - lateinit var appUpgradeHandler: AppUpgradeHandler - @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver @Inject lateinit var imageUrlService: ImageUrlService + @Inject + lateinit var seerrRatingsService: SeerrRatingsService + @Inject lateinit var refreshRateService: RefreshRateService @@ -123,6 +134,12 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService + + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -131,7 +148,20 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + @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) override fun onCreate(savedInstanceState: Bundle?) { @@ -139,178 +169,123 @@ class MainActivity : AppCompatActivity() { instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) - if (savedInstanceState == null) { - lifecycleScope.launchIO { - appUpgradeHandler.copySubfont(false) - } - } - 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.appStart() - 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 + + val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) + 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 } - if (showLoading) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - LoadingPage() + } else { + null + } + if (restoredBackStack != null) { + Timber.d("Restoring back stack") + var backStack = restoredBackStack + if (!playExternalViewModel.launched.value) { + val lastDest = backStack.lastOrNull() + if (lastDest.isPlayback) { + Timber.v("Restoring back stack without playback") + backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + } + } + navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) + } else { + navigationManager.backStack = NavBackStack(Destination.Home()) + } + + 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) } } - } - appPreferences?.let { appPreferences -> - LaunchedEffect(appPreferences.signInAutomatically) { - signInAuto = appPreferences.signInAutomatically + }.catch { ex -> + Timber.e(ex, "Error with settings flag secure") + }.launchIn(lifecycleScope) + + screensaverService.keepScreenOn + .onEach { keepScreenOn -> + Timber.v("keepScreenOn: %s", keepScreenOn) + withContext(WholphinDispatchers.Main) { + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } } - CoilConfig( - diskCacheSizeBytes = - appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { - if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) { - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT - } else { - it + }.catch { ex -> + Timber.e(ex, "Error with keepScreenOn") + }.launchIn(lifecycleScope) + + userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> + signInAuto = prefs.signInAutomatically + playerBackend = prefs.playbackPreferences.playerBackend + } + + viewModel.appStart(intent) + setContent { + 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() } - }, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.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( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - ApplicationContent( - user = current.user, - server = current.server, - startDestination = - requestedDestination - ?: Destination.Home(), - 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), - ) - } - } - } - } - } - } - }, + } + } 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(), + ) + } + } + } } } } @@ -318,23 +293,44 @@ class MainActivity : AppCompatActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (screensaverService.state.value.run { show || showDim }) { + screensaverService.stop(false) + screensaverService.pulse() + return true + } else { + screensaverService.pulse() + return super.dispatchKeyEvent(event) + } + } + override fun onResume() { super.onResume() Timber.d("onResume") - lifecycleScope.launchIO { - appUpgradeHandler.run() + viewModel.appResume() + lifecycleScope.launchDefault { + screensaverService.pulse() } } 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() + if (lastDest.isPlayback) { + Timber.v("onRestart: go back from playback") + navigationManager.goBack() + } + } } override fun onStop() { super.onStop() Timber.d("onStop") + screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } @@ -346,11 +342,36 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") + 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) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -365,66 +386,36 @@ 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()) { - 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" lateinit var instance: MainActivity private set @@ -435,59 +426,168 @@ class MainActivity : AppCompatActivity() { class MainActivityViewModel @Inject constructor( + @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() { - viewModelScope.launchIO { - try { - 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) - } + private val mutex = Mutex() + + fun appStart(intent: Intent?) { + viewModelScope.launchDefault { + mutex.withLock { + try { + handleAppUpgrade() + } catch (ex: Exception) { + Timber.e(ex, "Error during app upgrade!") + showToast( + context, + "Error during app upgrade: ${ex.localizedMessage}", + ) + } + + 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.launchIO { + viewModelScope.launchDefault { // Create the mediaCodecCapabilitiesTest if needed 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 + get() = + this is Destination.Playback || + this is Destination.PlaybackList || + this is Destination.Slideshow diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..fcaa3165 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,164 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +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.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 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, + userPreferences: UserPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + modifier: Modifier = Modifier, +) { + val preferences by rememberUpdatedState(userPreferences) + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + 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, + ) { + 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 (!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()) + } + } + AnimatedVisibility( + visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show), + enter = ScreensaverService.enterAnimation, + exit = ScreensaverService.exitAnimation, + modifier = 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 cf2c159a..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -3,19 +3,19 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer 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 @@ -27,16 +27,6 @@ class WholphinApplication : init { instance = this - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .build(), - ) - } - if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { @@ -64,7 +54,23 @@ class WholphinApplication : override fun onCreate() { super.onCreate() - OkHttp.initialize(this) + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectNetwork() + .penaltyLog() + .penaltyDeathOnNetwork() + .build(), + ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) + } initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON @@ -114,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 new file mode 100644 index 00000000..3bc17f3c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -0,0 +1,148 @@ +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.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 +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +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 +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.ui.CoilConfig +import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.launchDefault +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 okhttp3.OkHttpClient +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@AndroidEntryPoint +class WholphinDreamService : + DreamService(), + SavedStateRegistryOwner { + @Inject + lateinit var serverRepository: ServerRepository + + @Inject + lateinit var screensaverService: ScreensaverService + + @Inject + lateinit var preferencesDataStore: DataStore<AppPreferences> + + @AuthOkHttpClient + @Inject + lateinit var okHttpClient: OkHttpClient + + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + Timber.d("onCreate") + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + lifecycleScope.launchDefault { + if (serverRepository.current.value == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { + Timber.w("Could not restore user session") + } + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + Timber.d("onAttachedToWindow") + val itemFlow = screensaverService.createItemFlow(lifecycleScope) + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@WholphinDreamService) + setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) + setContent { + val user by serverRepository.currentUserFlow.collectAsState(null) + if (user != null) { + var prefs by remember { mutableStateOf<AppPreferences?>(null) } + LaunchedEffect(Unit) { + preferencesDataStore.data.collectLatest { prefs = it } + } + prefs?.let { prefs -> + CoilConfig( + prefs = prefs, + okHttpClient = okHttpClient, + debugLogging = false, + enableCache = true, + ) + WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) { + ProvideLocalClock { + val screensaverPrefs = prefs.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + 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)), + ) + } + } + } + } + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + Timber.d("onDreamingStarted") + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + Timber.d("onDreamingStopped") + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } +} 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 a3629843..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 = 30, + version = 41, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -54,6 +65,13 @@ import java.util.UUID AutoMigration(11, 12), 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) @@ -71,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("-", "") @@ -93,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 585e1079..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,58 +4,115 @@ 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 + */ sealed interface ExtrasItem { val parentId: UUID val type: ExtraType val destination: Destination - val title: String? + val imageUrl: String? + val title: String + val subtitle: String? + val isPlayed: Boolean + val playedPercentage: Double + val aspectRatio: Float? + /** + * Represents multiple extras of the same type + */ data class Group( override val parentId: UUID, override val type: ExtraType, val items: List<BaseItem>, + override val imageUrl: String?, + 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 title: String? = null + override val playedPercentage + get() = -1.0 } + /** + * Represents a single extra + */ data class Single( override val parentId: UUID, override val type: ExtraType, val item: BaseItem, + override val imageUrl: String?, + override val title: String, + override val subtitle: String?, ) : ExtrasItem { override val destination: Destination = Destination.Playback( item = item, ) - override val title: String? get() = item.title + override val isPlayed: Boolean + get() = item.played + override val playedPercentage + get() = item.data.userData?.playedPercentage ?: 0.0 + override val aspectRatio: Float? + get() = item.aspectRatio } } +/** + * Converts [ExtraType] to the string resource ID + */ @get:StringRes 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 } +/** + * Converts [ExtraType] to the plural resource ID + */ @get:PluralsRes val ExtraType.pluralRes: Int get() = 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/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt index 0124ac3f..524281cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.ui.toServerString +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao { itemId: String, ): LibraryDisplayInfo? + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") + fun getItemAsFlow( + userId: Int, + itemId: String, + ): Flow<LibraryDisplayInfo?> + @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveItem(item: LibraryDisplayInfo): Long 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/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt deleted file mode 100644 index 2312c62c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.damontecres.wholphin.data - -import android.content.Context -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.services.SeerrServerRepository -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.util.supportedCollectionTypes -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.CollectionType -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NavDrawerItemRepository - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - ) { - suspend fun getNavDrawerItems(): List<NavDrawerItem> { - val user = serverRepository.currentUser.value - val tvAccess = - serverRepository.currentUserDto.value - ?.policy - ?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user?.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user?.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - - val builtins = - if (seerrServerRepository.active.first()) { - listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - } else { - listOf(NavDrawerItem.Favorites) - } - - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) - } else { - BaseItem.from(it, api).destination() - } - ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), - destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, - ) - } - return builtins + libraries - } - - suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> { - val user = serverRepository.currentUser.value - val navDrawerPins = - user - ?.let { - serverPreferencesDao.getNavDrawerPinnedItems(it) - }.orEmpty() - val filtered = items.filter { navDrawerPins.isPinned(it.id) } - if (items.size != filtered.size) { - // Some were filtered out, check if should include More - if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { - return filtered + listOf(NavDrawerItem.More) - } - } - return filtered - } - } - -fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED 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/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt index 59f5d9ea..3a2c325c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -47,7 +47,7 @@ interface SeerrServerDao { suspend fun deleteUser( serverId: Int, jellyfinUserRowId: Int, - ) + ): Int suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) 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 f66dcfca..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,29 +1,34 @@ 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.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.EqualityMutableLiveData +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher +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 import org.jellyfin.sdk.api.client.ApiClient +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.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 @@ -39,18 +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 val sharedPreferences = getServerSharedPreferences(context) + private var _current = MutableStateFlow<CurrentUser?>(null) + val current: StateFlow<CurrentUser?> = _current - private var _current = EqualityMutableLiveData<CurrentUser?>(null) - val current: LiveData<CurrentUser?> = _current + private var _currentUserDto = MutableStateFlow<ServerUserConfig?>(null) + val currentUserDto: ServerUserConfig? get() = _currentUserDto.value + val currentUserDtoFlow: StateFlow<ServerUserConfig?> get() = _currentUserDto - private var _currentUserDto = EqualityMutableLiveData<UserDto?>(null) - val currentUserDto: LiveData<UserDto?> = _currentUserDto + val currentServer: JellyfinServer? get() = _current.value?.server + val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } + val currentUser: JellyfinUser? get() = _current.value?.user - val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server } - val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.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 @@ -58,21 +80,22 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { 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, user: JellyfinUser, - ): CurrentUser? = - withContext(Dispatchers.IO) { + ): CurrentUser = + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -94,69 +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() - } - withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser) - _currentUserDto.value = userDto - } - sharedPreferences.edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } - return@withContext _current.value + + val currentUser = CurrentUser(updatedServer, updatedUser) + 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(Dispatchers.IO) { - 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(Dispatchers.IO) { - serverId?.let { serverDao.getServer(serverId)?.server } - } - - fun closeSession() { - _current.value = null - } /** * Given a successful [AuthenticationResult], switch to the user that just authenticated @@ -164,7 +219,8 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + existingUser: JellyfinUser?, + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -180,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) @@ -201,79 +269,80 @@ 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(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } 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(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } suspend fun switchServerOrUser() { - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() } - suspend fun setUserPin( + suspend fun updateUserAuth( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { - 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 } } } - companion object { - fun getServerSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "${context.packageName}_server", - Context.MODE_PRIVATE, - ) + suspend fun authorizeQuickConnect(code: String): Boolean = + withContext(ioDispatcher) { + 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") + } + val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId) + response.content + } - const val SERVER_URL_KEY = "current.server" - const val ACCESS_TOKEN_KEY = "current.accessToken" + /** + * Update [currentUserDto] by querying the server + */ + suspend fun updateUserDto() { + val userDto by apiClient.userApi.getCurrentUser() + updateUserDto(userDto) + } + + /** + * 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 fec0fb8d..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 @@ -17,6 +17,19 @@ val DefaultFilterOptions = DecadeFilter, ) +val DefaultTvFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + val DefaultForFavoritesFilterOptions = listOf( PlayedFilter, @@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions = listOf( PlayedFilter, FavoriteFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +val DefaultForStudiosFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, CommunityRatingFilter, OfficialRatingFilter, VideoTypeFilter, @@ -50,20 +76,30 @@ val DefaultPlaylistItemsOptions = DecadeFilter, ) -sealed interface ItemFilterBy<T> { +/** + * Abstract way to filter data + */ +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 @@ -178,3 +214,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> { filter: GetItemsFilter, ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) } + +data object StudioFilter : ItemFilterBy<List<UUID>> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios + + override fun set( + value: List<UUID>?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(studios = value) +} 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 new file mode 100644 index 00000000..cf8ce4d0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -0,0 +1,51 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.runtime.Stable +import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID +import kotlin.time.Duration + +/** + * Represents audio or a song as a stripped down [BaseItem] since there may be a lot of these created. + * + * Typically added to a MediaItem as the tag for reference later + * + * The "key" can be used by a Compose LazyList key function as it will uniquely identify this particular + * audio even if the same song is added to the queue multiple times + */ +@Stable +data class AudioItem( + val key: Long = keyTracker++, + val id: UUID, + val albumId: UUID?, + val artistId: UUID?, + val title: String?, + val albumTitle: String?, + val artistNames: String?, + val runtime: Duration?, + val imageUrl: String?, + val hasLyrics: Boolean, +) { + companion object { + private var keyTracker = 0L + + fun from( + item: BaseItem, + imageUrl: String?, + ): AudioItem = + AudioItem( + id = item.id, + albumId = item.data.albumId, + artistId = + item.data.artistItems + ?.firstOrNull() + ?.id, + title = item.title, + albumTitle = item.data.album, + 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 2d8c7baa..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,16 +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.ui.DateFormatter +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 @@ -18,20 +23,27 @@ 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 +/** + * Wrapper for [BaseItemDto] with shortcuts for various UI elements + */ @Serializable @Stable data class BaseItem( val data: BaseItemDto, - val useSeriesForPrimary: Boolean, + val useSeriesForPrimary: Boolean = false, + val imageUrlOverride: String? = null, + val destinationOverride: Destination? = null, ) : CardGridItem { val id get() = data.id @@ -41,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 @@ -49,13 +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 - 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) { @@ -63,14 +77,15 @@ data class BaseItem( add(data.seasonEpisodePadded) add(data.name) add(data.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") + }.joinNotBlank(" - ") } else { data.productionYear?.toString() } } - @Transient - val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } + val canDelete: Boolean get() = data.canDelete == true + + val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } val indexNumber get() = data.indexNumber @@ -82,78 +97,143 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - @Transient - val timeRemainingOrRuntime: Duration? = 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 + */ @Transient val ui = BaseItemUi( episodeCornerText = - data.indexNumber?.let { "E$it" } - ?: data.premiereDate?.let(::formatDateTime), - episodeUnplayedCornerText = - if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || 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(DateFormatter.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 { - 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? = @@ -164,28 +244,74 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() + /** + * Convert this [BaseItem] into a [Destination] to navigate to its page in the app + */ 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 -> { + Destination.Playback( + itemId = id, + positionMs = 0L, ) } + BaseItemKind.PROGRAM -> { + val channelId = data.channelId + if (channelId != null) { + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ) + } else { + Destination.MediaItem(this) + } + } + + BaseItemKind.AUDIO -> { + data.albumId?.let { albumId -> + Destination.MediaItem( + itemId = albumId, + type = BaseItemKind.MUSIC_ALBUM, + initialSongId = id, + ) + } ?: Destination.MediaItem(this) + } + else -> { Destination.MediaItem(this) } @@ -194,6 +320,7 @@ data class BaseItem( } companion object { + @Deprecated("Use regular constructor instead") fun from( dto: BaseItemDto, api: ApiClient, @@ -208,9 +335,79 @@ 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, +) + +/** + * Create the special [Destination.FilteredCollection] for the given genre information + */ +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List<BaseItemKind>?, + collectionType: CollectionType, +) = Destination.FilteredCollection( + itemId = parentId, + parentType = BaseItemKind.GENRE, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = true, + libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres", + ), + recursive = true, + collectionType = collectionType, +) + +fun createStudioDestination( + studioId: UUID, + name: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List<BaseItemKind>?, +) = Destination.FilteredCollection( + itemId = parentId, + parentType = BaseItemKind.STUDIO, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + name, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + studios = listOf(studioId), + includeItemTypes = includeItemTypes, + ), + 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 1bcacb3f..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,36 +1,32 @@ 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, ) - }.orEmpty() + }?.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 a274caa1..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 @@ -2,28 +2,21 @@ package com.github.damontecres.wholphin.data.model -import com.github.damontecres.wholphin.api.seerr.model.CreditCast -import com.github.damontecres.wholphin.api.seerr.model.CreditCrew -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.MovieResult -import com.github.damontecres.wholphin.api.seerr.model.TvDetails -import com.github.damontecres.wholphin.api.seerr.model.TvResult -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.services.SeerrSearchResult +import androidx.compose.runtime.Stable import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.toLocalDate import com.github.damontecres.wholphin.util.LocalDateSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.UUIDSerializer -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.time.LocalDate import java.util.UUID +/** + * The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind] + */ @Serializable enum class SeerrItemType( val baseItemKind: BaseItemKind?, @@ -54,16 +47,21 @@ enum class SeerrItemType( } } +/** + * How available is a particular discovered item within the Jellyfin server + */ @Serializable 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 { @@ -71,9 +69,27 @@ 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. + * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. */ +@Stable @Serializable data class DiscoverItem( val id: Int, @@ -83,26 +99,18 @@ data class DiscoverItem( val overview: String?, val availability: SeerrAvailability, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, - val posterPath: String?, - val backdropPath: String?, + val posterUrl: String?, + val backDropUrl: String?, + val logoUrl: String?, val jellyfinItemId: UUID?, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = title ?: "" - val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" } - val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } - 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, @@ -112,115 +120,4 @@ data class DiscoverItem( Destination.DiscoveredItem(this) } } - - constructor(movie: MovieResult) : this( - id = movie.id, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterPath = movie.posterPath, - backdropPath = movie.backdropPath, - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(movie: MovieDetails) : this( - id = movie.id ?: -1, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterPath = movie.posterPath, - backdropPath = movie.backdropPath, - jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(tv: TvResult) : this( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterPath = tv.posterPath, - backdropPath = tv.backdropPath, - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(tv: TvDetails) : this( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterPath = tv.posterPath, - backdropPath = tv.backdropPath, - jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(search: SeerrSearchResult) : this( - id = search.id, - type = SeerrItemType.fromString(search.mediaType), - title = search.title ?: search.name, - subtitle = null, - overview = search.overview, - availability = - SeerrAvailability.from(search.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), - posterPath = search.posterPath, - backdropPath = search.backdropPath, - jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(credit: CreditCast) : this( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.character, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), - posterPath = credit.posterPath ?: credit.profilePath, - backdropPath = credit.backdropPath, - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) - - constructor(credit: CreditCrew) : this( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.job, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.firstAirDate), - posterPath = credit.posterPath ?: credit.profilePath, - backdropPath = credit.backdropPath, - jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), - ) -} - -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 f819c7c3..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,21 +9,33 @@ 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 import java.util.UUID +/** + * Filter for a collection folder + */ @Serializable 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, ) +/** + * A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters + */ @Serializable data class GetItemsFilter( val favorite: Boolean? = null, @@ -52,7 +64,7 @@ data class GetItemsFilter( } /** - * Clear all of the values for the given filters + * Clear all the values for the given filters */ fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter { var newFilter = this @@ -128,6 +140,41 @@ 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 + */ fun merge(filter: GetItemsFilter): GetItemsFilter = this.copy( favorite = favorite ?: filter.favorite, @@ -149,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 new file mode 100644 index 00000000..39b82bc8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -0,0 +1,327 @@ +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) + +package com.github.damontecres.wholphin.data.model + +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.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 + +@Serializable +sealed interface HomeRowConfig { + val viewOptions: HomeRowViewOptions + + fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig + + /** + * Continue watching media that the user has started but not finished + */ + @Serializable + @SerialName("ContinueWatching") + data class ContinueWatching( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions) + } + + /** + * Next up row for next episodes in a series the user has started + */ + @Serializable + @SerialName("NextUp") + data class NextUp( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions) + } + + /** + * Combined [ContinueWatching] and [NextUp] + */ + @Serializable + @SerialName("ContinueWatchingCombined") + data class ContinueWatchingCombined( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently added to a library + */ + @Serializable + @SerialName("RecentlyAdded") + data class RecentlyAdded( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently released (premiere date) in a library + */ + @Serializable + @SerialName("RecentlyReleased") + data class RecentlyReleased( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions) + } + + /** + * Row of a genres in a library + */ + @Serializable + @SerialName("Genres") + data class Genres( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) + } + + /** + * Row of a studios in a library + */ + @Serializable + @SerialName("Studios") + data class Studios( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions) + } + + /** + * Favorites for a specific type + */ + @Serializable + @SerialName("Favorite") + data class Favorite( + val kind: BaseItemKind, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) + } + + /** + * Currently recording + */ + @Serializable + @SerialName("Recordings") + data class Recordings( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions) + } + + /** + * Programs on now/recommended + */ + @Serializable + @SerialName("TvPrograms") + data class TvPrograms( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) + } + + /** + * Live TV channels + */ + @Serializable + @SerialName("TvChannels") + data class TvChannels( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID + */ + @Serializable + @SerialName("Suggestions") + data class Suggestions( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting + */ + @Serializable + @SerialName("ByParent") + data class ByParent( + val parentId: UUID, + val recursive: Boolean = false, + val sort: SortAndDirection? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions) + } + + /** + * An arbitrary [HomeGetItemsFilter] allowing to query for most anything + */ + @Serializable + @SerialName("GetItems") + data class GetItems( + val name: String, + 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 + * + * Contains the list of rows and a version + */ +@Serializable +@SerialName("HomePageSettings") +data class HomePageSettings( + val rows: List<HomeRowConfig>, + val version: Int, +) { + companion object { + val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + } +} + +/** + * This is the max version supported by this version of the app + */ +const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 + +/** + * View options for displaying a row + * + * Allows for changing things like height or aspect ratio + */ +@Serializable +data class HomeRowViewOptions( + val heightDp: Int = Cards.HEIGHT_2X3_DP, + val spacing: Int = 16, + val contentScale: PrefContentScale = PrefContentScale.FILL, + val aspectRatio: AspectRatio = AspectRatio.TALL, + val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + val showTitles: Boolean = false, + val useSeries: Boolean = true, + val episodeContentScale: PrefContentScale = PrefContentScale.FILL, + val episodeAspectRatio: AspectRatio = AspectRatio.TALL, + val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, +) { + companion object { + val genreDefault = + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + + val liveTvDefault = + HomeRowViewOptions( + 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/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 94a30475..b31261fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Store the media source and audio/subtitle tracks chosen for a specific media item + * + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt index e8bbfeb4..3888e438 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt @@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +/** + * Store modifications to an audio/subtitle track in a media item + * + * For example, the subtitle delay + */ @Entity( foreignKeys = [ ForeignKey( 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 2c516272..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,13 +10,21 @@ 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 +/** + * Represents a Jellyfin server + */ @Entity(tableName = "servers") @Serializable data class JellyfinServer( @@ -24,11 +32,15 @@ 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) } } +/** + * Represents a Jellyfin user for a particular server + */ @Entity( tableName = "users", foreignKeys = [ @@ -52,13 +64,35 @@ 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] + */ data class JellyfinServerUsers( @Embedded val server: JellyfinServer, @Relation( @@ -67,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/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index eb8ffdd1..edfc797a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -9,13 +9,20 @@ import androidx.room.Ignore import androidx.room.Index import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID +/** + * Stores the filter, sort, and view options a user changes for a library + * + * This allows for restoring these settings whenever the user navigates to the library + */ @Entity( foreignKeys = [ ForeignKey( @@ -41,4 +48,13 @@ data class LibraryDisplayInfo( ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) + + constructor( + user: JellyfinUser, + itemId: UUID, + sort: ItemSortBy, + direction: SortOrder, + filter: GetItemsFilter, + viewOptions: ViewOptions?, + ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt new file mode 100644 index 00000000..e312a2b1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +enum class NavPinType { + PINNED, + UNPINNED, +} + +/** + * Stores preference information about nav drawer items such as its order and whether to show or put in More + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "itemId"], +) +data class NavDrawerPinnedItem( + val userId: Int, + val itemId: String, + val type: NavPinType, + @ColumnInfo(defaultValue = "-1") val order: Int, +) 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 c12d37b5..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 @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -8,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.PersonKind +/** + * Represents a person in some media such as an actor or director + */ @Stable data class Person( val id: UUID, @@ -19,19 +26,21 @@ data class Person( ) { companion object { fun fromDto( + context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = dto.role, + 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, ) fun fromDto( + context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -39,10 +48,51 @@ data class Person( Person( id = dto.id, name = dto.name, - role = dto.role, + 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, ) } } + +private fun personRole( + context: Context, + role: String?, + type: PersonKind, +): String? = + if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { + role + } else if (role.equals(type.name, ignoreCase = true)) { + type.stringRes?.let { context.getString(it) } + } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { + role + } else { + listOfNotNull( + role?.takeIf { it.isNotNullOrBlank() }, + type.stringRes?.let { context.getString(it) }, + ).takeIf { it.isNotEmpty() }?.joinToString(" - ") + } + +@get:StringRes +private val PersonKind.stringRes: Int? + get() = + when (this) { + PersonKind.UNKNOWN -> R.string.unknown + PersonKind.ACTOR -> R.string.actor + PersonKind.DIRECTOR -> R.string.director + PersonKind.COMPOSER -> R.string.composer + PersonKind.WRITER -> R.string.writer + PersonKind.GUEST_STAR -> R.string.guest_star + PersonKind.PRODUCER -> R.string.producer + PersonKind.CONDUCTOR -> R.string.conductor + PersonKind.LYRICIST -> R.string.lyricist + PersonKind.ARRANGER -> R.string.arranger + PersonKind.ENGINEER -> R.string.engineer + PersonKind.MIXER -> R.string.mixer + PersonKind.REMIXER -> R.string.mixer + PersonKind.CREATOR -> R.string.creator + PersonKind.ARTIST -> R.string.artist + PersonKind.ALBUM_ARTIST -> R.string.artist + else -> null + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt index 3c5cf6a0..0e7bcd37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt @@ -5,6 +5,9 @@ import androidx.room.Entity import org.jellyfin.sdk.model.api.BaseItemKind import java.util.UUID +/** + * Store effects applied to some media such as color or hue adjustments + */ @Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) data class PlaybackEffect( val jellyfinUserRowId: Int, 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 f452e2f1..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 @@ -9,6 +9,15 @@ import kotlinx.serialization.UseSerializers 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 c4cd3792..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,43 +1,20 @@ 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 -class Playlist( - items: List<BaseItem>, - startIndex: Int = 0, +/** + * Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones. + * + * This is not the same thing as a Jellyfin server playlist + */ +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(): BaseItem = items[--index] - - fun getAndAdvance(): BaseItem = items[++index] - - fun peek(): BaseItem? = items.getOrNull(index + 1) - - fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size) - - fun advanceTo(id: UUID): BaseItem? { - 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) }) } } @@ -47,3 +24,22 @@ data class PlaylistInfo( val count: Int, val mediaType: MediaType, ) + +sealed interface PlaylistItem { + val id: UUID + val item: BaseItem + + data class Media( + override val item: BaseItem, + ) : PlaylistItem { + override val id: UUID + get() = item.id + } + + data class Intro( + override val item: BaseItem, + ) : PlaylistItem { + override val id: UUID + get() = item.id + } +} 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/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt similarity index 78% rename from app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt rename to app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt index f3f52585..2fc6f702 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt @@ -2,11 +2,7 @@ package com.github.damontecres.wholphin.data.model import androidx.room.Entity import androidx.room.ForeignKey - -enum class NavPinType { - PINNED, - UNPINNED, -} +import androidx.room.Index @Entity( foreignKeys = [ @@ -18,10 +14,11 @@ enum class NavPinType { onUpdate = ForeignKey.CASCADE, ), ], + indices = [Index("userId", "itemId", unique = true)], primaryKeys = ["userId", "itemId"], ) -data class NavDrawerPinnedItem( +data class RememberedTab( val userId: Int, val itemId: String, - val type: NavPinType, + 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 1e9f963d..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 @@ -2,8 +2,12 @@ package com.github.damontecres.wholphin.data.model import com.github.damontecres.wholphin.services.SeerrUserConfig +/** + * Permission levels for a user on a Seerr server + */ 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), @@ -14,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 @@ -45,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/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt index e29305d2..311379e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.serializer.UUIDSerializer +/** + * Represents a Seerr server instance + */ @Entity( tableName = "seerr_servers", indices = [Index("url", unique = true)], @@ -26,6 +29,9 @@ data class SeerrServer( val version: String? = null, ) +/** + * Represents a user on a [SeerrServer] + */ @Entity( tableName = "seerr_users", foreignKeys = [ @@ -56,12 +62,18 @@ data class SeerrUser( "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" } +/** + * The method used to authenticate a user to the server + */ enum class SeerrAuthMethod { LOCAL, JELLYFIN, API_KEY, } +/** + * Represents the relationship between a [SeerrServer] and its [SeerrUser]s + */ data class SeerrServerUsers( @Embedded val server: SeerrServer, @Relation( 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/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt index 943789db..01e473b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt @@ -1,9 +1,15 @@ package com.github.damontecres.wholphin.data.model +/** + * Represents a trailer for media + */ sealed interface Trailer { val name: String } +/** + * A [Trailer] stored on the Jellyfin server + */ data class LocalTrailer( val baseItem: BaseItem, ) : Trailer { @@ -11,6 +17,9 @@ data class LocalTrailer( get() = baseItem.name ?: "" } +/** + * A [Trailer] available via a remote URL, such as YouTube + */ data class RemoteTrailer( override val name: String, val url: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt index e71db95c..93543e23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt @@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation import androidx.room.Ignore /** - * Modifications to a video playback + * Modifications to an image or video playback */ data class VideoFilter( val rotation: Int = 0, 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 4d7dd76f..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 @@ -5,9 +5,11 @@ import androidx.annotation.ArrayRes import androidx.annotation.StringRes import androidx.core.content.edit import androidx.preference.PreferenceManager +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 @@ -52,14 +54,17 @@ 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 = AppSliderPreference<AppPreferences>( title = R.string.skip_forward_preference, defaultValue = 30, - min = 10, + min = 5, max = 5.minutes.inWholeSeconds, interval = 5, getter = { @@ -113,30 +118,6 @@ sealed interface AppPreference<Pref, T> { }, ) -// val GridJumpButtons = -// AppSwitchPreference<AppPreferences>( -// title = R.string.show_grid_jump_buttons, -// defaultValue = true, -// getter = { it.interfacePreferences.showGridJumpButtons }, -// setter = { prefs, value -> -// prefs.updateInterfacePreferences { showGridJumpButtons = value } -// }, -// summaryOn = R.string.enabled, -// summaryOff = R.string.disabled, -// ) - -// val ShowGridFooter = -// AppSwitchPreference<AppPreferences>( -// title = R.string.grid_position_footer, -// defaultValue = true, -// getter = { it.interfacePreferences.showPositionFooter }, -// setter = { prefs, value -> -// prefs.updateInterfacePreferences { showPositionFooter = value } -// }, -// summaryOn = R.string.show, -// summaryOff = R.string.hide, -// ) - val ControllerTimeout = AppSliderPreference<AppPreferences>( title = R.string.hide_controller_timeout, @@ -225,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, @@ -249,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, @@ -364,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, @@ -397,49 +379,67 @@ 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, ) - val DirectPlayAss = - AppSwitchPreference<AppPreferences>( - title = R.string.direct_play_ass, - defaultValue = true, - getter = { it.playbackPreferences.overrides.directPlayAss }, + val AssSubtitleMode = + AppChoicePreference<AppPreferences, AssPlaybackMode>( + title = R.string.ass_subtitle_playback, + defaultValue = AssPlaybackMode.ASS_LIBASS, + getter = { it.playbackPreferences.overrides.assPlaybackMode }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayAss = value } + prefs.updatePlaybackOverrides { assPlaybackMode = value } }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, + displayValues = R.array.ass_subtitle_modes, + subtitles = R.array.ass_subtitle_modes_summary, + indexToValue = { AssPlaybackMode.forNumber(it) }, + valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number }, ) val DirectPlayPgs = AppSwitchPreference<AppPreferences>( @@ -453,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 = @@ -476,6 +530,18 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) + val CinemaMode = + AppSwitchPreference<AppPreferences>( + title = R.string.cinema_mode, + defaultValue = true, + getter = { it.playbackPreferences.cinemaMode }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { cinemaMode = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val RememberSelectedTab = AppSwitchPreference<AppPreferences>( title = R.string.remember_selected_tab, @@ -501,6 +567,37 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, ) + val ShowLogos = + AppSwitchPreference<AppPreferences>( + title = R.string.prefer_logos, + defaultValue = true, + getter = { it.interfacePreferences.showLogos }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { showLogos = value } + }, + summaryOn = R.string.enabled, + 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, @@ -530,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 } @@ -550,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, @@ -615,6 +757,27 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) + val SkipSegments = + AppDestinationPreference<AppPreferences>( + title = R.string.skip_behavior, + summary = R.string.skip_behavior_summary, + 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, @@ -656,6 +819,20 @@ sealed interface AppPreference<Pref, T> { setter = { prefs, _ -> prefs }, ) + val CustomizeHome = + AppDestinationPreference<AppPreferences>( + title = R.string.customize_home, + destination = Destination.HomeSettings, + 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, @@ -734,6 +911,18 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference<AppPreferences>( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference<AppPreferences>( title = R.string.one_click_pause, @@ -792,8 +981,19 @@ sealed interface AppPreference<Pref, T> { }, displayValues = R.array.player_backend_options, subtitles = R.array.player_backend_options_subtitles, - indexToValue = { PlayerBackend.forNumber(it) }, - valueToIndex = { it.number }, + indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER }, + valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number }, + ) + + val ExternalPlayerApp = + AppStringPreference<AppPreferences>( + title = R.string.external_player, + defaultValue = "", + getter = { it.playbackPreferences.externalPlayer }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { externalPlayer = value } + }, + summary = null, ) val ExoPlayerSettings = @@ -808,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, @@ -862,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 = @@ -943,6 +1149,14 @@ sealed interface AppPreference<Pref, T> { setter = { prefs, _ -> prefs }, ) + val QuickConnect = + AppClickablePreference<AppPreferences>( + title = R.string.quick_connect, + summary = R.string.quick_connect_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SlideshowDuration = AppSliderPreference<AppPreferences>( title = R.string.slideshow_duration, @@ -982,6 +1196,12 @@ sealed interface AppPreference<Pref, T> { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val ScreensaverSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.screensaver_settings, + destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), + ) } } @@ -992,14 +1212,11 @@ val basicPreferences = preferences = listOf( AppPreference.SignInAuto, - AppPreference.HomePageItems, - AppPreference.CombineContinueNext, - AppPreference.RewatchNextUp, - AppPreference.MaxDaysNextUp, - AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, + AppPreference.PlayThemeMusic, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -1025,8 +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( @@ -1042,10 +1262,12 @@ val basicPreferences = PreferenceGroup( title = R.string.more, preferences = - listOf( - AppPreference.SeerrIntegration, - AppPreference.AdvancedSettings, - ), + buildList { + if (BuildConfig.DISCOVER_ENABLED) { + add(AppPreference.SeerrIntegration) + } + add(AppPreference.AdvancedSettings) + }, ), ) @@ -1054,10 +1276,19 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, - AppPreference.DirectPlayAss, + 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 = @@ -1068,6 +1299,14 @@ val ExoPlayerPreferences = ), ) +val HdrFormatPreferences = + listOf( + PreferenceGroup( + title = R.string.hdr_formats, + preferences = HdrFormatSettings, + ), + ) + private val MpvSettings = listOf( AppPreference.MpvHardwareDecoding, @@ -1083,6 +1322,21 @@ val MpvPreferences = ), ) +val SkipSegmentPreferences = + listOf( + PreferenceGroup( + title = R.string.skip, + preferences = + listOf( + AppPreference.SkipIntros, + AppPreference.SkipOutros, + AppPreference.SkipCommercials, + AppPreference.SkipPreviews, + AppPreference.SkipRecaps, + ), + ), + ) + val advancedPreferences = buildList { add( @@ -1090,11 +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.DisplayTogglesPref, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, - AppPreference.ControllerTimeout, - AppPreference.BackdropStylePref, AppPreference.SlideshowDuration, AppPreference.SlideshowPlayVideos, ), @@ -1106,7 +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, @@ -1114,19 +1378,6 @@ val advancedPreferences = ), ), ) - add( - PreferenceGroup( - title = R.string.skip, - preferences = - listOf( - AppPreference.SkipIntros, - AppPreference.SkipOutros, - AppPreference.SkipCommercials, - AppPreference.SkipPreviews, - AppPreference.SkipRecaps, - ), - ), - ) add( PreferenceGroup( title = R.string.player_backend, @@ -1148,6 +1399,10 @@ val advancedPreferences = AppPreference.MpvSettings, ), ), + ConditionalPreferences( + { it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER }, + listOf(AppPreference.ExternalPlayerApp), + ), ), ), ) @@ -1168,13 +1423,22 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.QuickConnect, AppPreference.SendAppLogs, AppPreference.SendCrashReports, AppPreference.DebugLogging, AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, + ExperimentalPreference.Enable, AppPreference.OssLicenseInfo, ), + conditionalPreferences = + listOf( + ConditionalPreferences( + condition = { it.experimentalPreferences.enabled }, + preferences = listOf(ExperimentalPreference.ExperimentalSettings), + ), + ), ), ) } @@ -1187,16 +1451,47 @@ val liveTvPreferences = AppPreference.LiveTvColorCodePrograms, ) +val screensaverPreferences = + listOf( + PreferenceGroup( + title = R.string.screensaver, + preferences = + listOf( + ScreensaverPreference.Enabled, + ScreensaverPreference.StartDelay, + ScreensaverPreference.Duration, + ScreensaverPreference.ShowClock, + ScreensaverPreference.Animate, + ScreensaverPreference.MaxAge, + ScreensaverPreference.ItemTypes, + ScreensaverPreference.Start, + ), + ), + PreferenceGroup( + title = R.string.dim_screen, + preferences = + listOf( + ScreensaverPreference.DimToggle, + ScreensaverPreference.DimPercentage, + ), + ), + ) + data class AppSwitchPreference<Pref>( @get:StringRes override val title: Int, 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?, @@ -1241,8 +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, - val toSharedPrefs: (T) -> String, - val fromSharedPrefs: (String) -> T?, + @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 6adcdca4..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 @@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException +import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -53,6 +54,8 @@ class AppPreferencesSerializer refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue + cinemaMode = AppPreference.CinemaMode.defaultValue + dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides @@ -60,10 +63,12 @@ class AppPreferencesSerializer .apply { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue + assPlaybackMode = + AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = @@ -81,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 = @@ -94,6 +98,17 @@ class AppPreferencesSerializer AppPreference.NavDrawerSwitchOnFocus.defaultValue 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 @@ -120,6 +135,20 @@ class AppPreferencesSerializer colorCodePrograms = AppPreference.LiveTvColorCodePrograms.defaultValue }.build() + + screensaverPreference = + ScreensaverPreferences + .newBuilder() + .apply { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + showClock = ScreensaverPreference.ShowClock.defaultValue + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + }.build() }.build() advancedPreferences = @@ -137,6 +166,29 @@ class AppPreferencesSerializer slideshowDuration = AppPreference.SlideshowDuration.defaultValue slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue }.build() + + musicPreferences = + MusicPreferences + .newBuilder() + .apply { + showBackdrop = true + 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 { @@ -180,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() @@ -200,6 +257,21 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder photoPreferences = photoPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + screensaverPreference = screensaverPreference.toBuilder().apply(block).build() + } + +inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences = + update { + 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 new file mode 100644 index 00000000..04fb4a63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -0,0 +1,214 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.time.Duration.Companion.milliseconds + +object ScreensaverPreference { + val Enabled = + AppSwitchPreference<AppPreferences>( + title = R.string.in_app_screensaver, + defaultValue = false, + getter = { it.interfacePreferences.screensaverPreference.enabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + const val DEFAULT_START_DELAY = 15 * 60_000L + private val startDelayValues = + listOf( + 30_000L, + 60_000L, + 2 * 60_000L, + 5 * 60_000L, + 10 * 60_000L, + 15 * 60_000L, + 30 * 60_000L, + 60 * 60_000L, + ) + val StartDelay = + AppSliderPreference<AppPreferences>( + title = R.string.start_after, + defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), + min = 0, + max = startDelayValues.size - 1L, + interval = 1, + getter = { + startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + startDelay = startDelayValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY + v.milliseconds.toString() + } else { + null + } + }, + ) + + const val DEFAULT_DURATION = 30_000L + private val durationValues = + listOf( + 15_000L, + 30_000L, + 60_000L, + 2 * 60_000L, + ) + val Duration = + AppSliderPreference<AppPreferences>( + title = R.string.duration, + defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), + min = 0, + max = durationValues.size - 1L, + interval = 1, + getter = { + durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + duration = durationValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION + v.milliseconds.toString() + } else { + null + } + }, + ) + + val ShowClock = + AppSwitchPreference<AppPreferences>( + title = R.string.show_clock, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.showClock }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { showClock = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val Animate = + AppSwitchPreference<AppPreferences>( + title = R.string.animate, + defaultValue = true, + getter = { it.interfacePreferences.screensaverPreference.animate }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { animate = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + const val DEFAULT_MAX_AGE = 16 + private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) + val MaxAge = + AppSliderPreference<AppPreferences>( + title = R.string.max_age_rating, + defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), + min = 0, + max = maxAgeValues.size - 1L, + interval = 1, + getter = { + it.interfacePreferences.screensaverPreference.maxAgeFilter + .takeIf { it >= 0 } + ?.let { maxAgeValues.indexOf(it).toLong() } + ?: maxAgeValues.lastIndex.toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + maxAgeFilter = maxAgeValues[value.toInt()] + } + }, + summarizer = { value -> + when (value) { + null -> { + null + } + + maxAgeValues.lastIndex.toLong() -> { + WholphinApplication.instance.getString(R.string.no_max) + } + + 0L -> { + WholphinApplication.instance.getString(R.string.for_all_ages) + } + + else -> { + WholphinApplication.instance.getString( + R.string.up_to_age, + maxAgeValues[value.toInt()].toString(), + ) + } + } + }, + ) + + val ItemTypes = + AppMultiChoicePreference<AppPreferences, BaseItemKind>( + title = R.string.include_types, + summary = R.string.include_types_summary, + defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), + displayValues = R.array.screensaver_item_types, + getter = { + it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> + BaseItemKind.fromName(type) + } + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + clearItemTypes() + addAllItemTypes(value.map { it.serialName }) + } + }, + ) + + 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 057ec550..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 @@ -1,40 +1,76 @@ package com.github.damontecres.wholphin.services import android.content.Context +import android.content.pm.PackageInfo import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.WholphinApplication +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 import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updateMusicPreferences 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 +/** + * Handles any changes needed when the app in upgraded on the device such as setting new preferences + */ @Singleton class AppUpgradeHandler @Inject constructor( @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, ) { - suspend fun run() { - val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0) + val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) + val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) + + fun needUpgrade(): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) @@ -46,30 +82,48 @@ class AppUpgradeHandler } else { pkgInfo.versionCode.toLong() } - if (newVersion != previousVersion || newVersionCode != previousVersionCode) { - Timber.i( - "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", - ) - prefs.edit(true) { - putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) - putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) - putString(VERSION_NAME_CURRENT_KEY, newVersion) - putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) - } - try { - copySubfont(true) - upgradeApp( - context, - Version.Companion.fromString(previousVersion ?: "0.0.0"), - Version.Companion.fromString(newVersion), - appPreferences, - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception during app upgrade") - } - } + Timber.i( + "App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", + ) + return newVersion != previousVersion || newVersionCode != previousVersionCode } + suspend fun run() { + Timber.i("App upgrade started") + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) + val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) + + val newVersion = pkgInfo.versionName!! + val newVersionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkgInfo.longVersionCode + } else { + pkgInfo.versionCode.toLong() + } + // Store the previous and new version info + prefs.edit(true) { + putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) + putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) + putString(VERSION_NAME_CURRENT_KEY, newVersion) + putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) + } + try { + copySubfont(true) + upgradeApp( + Version.fromString(previousVersion ?: "0.0.0"), + Version.fromString(newVersion), + appPreferences, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception during app upgrade") + } + Timber.i("App upgrade complete") + } + + /** + * Copies the font file used by MPV subtitles to the app's files directory + */ fun copySubfont(overwrite: Boolean) { try { val fontFileName = "subfont.ttf" @@ -97,110 +151,111 @@ class AppUpgradeHandler const val VERSION_NAME_CURRENT_KEY = "version.current.name" const val VERSION_CODE_CURRENT_KEY = "version.current.code" } - } -suspend fun upgradeApp( - context: Context, - previous: Version, - current: Version, - appPreferences: DataStore<AppPreferences>, -) { - if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { - appPreferences.updateData { - it.updatePlaybackOverrides { - ac3Supported = AppPreference.Ac3Supported.defaultValue - downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue - directPlayPgs = AppPreference.DirectPlayPgs.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - showClock = AppPreference.ShowClock.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { - PreferencesViewModel.resetSubtitleSettings(appPreferences) - } - if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - margin = SubtitleSettings.Margin.defaultValue.toInt() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { - appPreferences.updateData { - it.updateAdvancedPreferences { - if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { - imageDiskCacheSizeBytes = - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + /** + * Perform any needed upgrades + */ + suspend fun upgradeApp( + previous: Version, + current: Version, + appPreferences: DataStore<AppPreferences>, + ) { + if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + ac3Supported = AppPreference.Ac3Supported.defaultValue + downmixStereo = AppPreference.DownMixStereo.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue + directPlayPgs = AppPreference.DirectPlayPgs.defaultValue + } } } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = AppPreference.MpvGpuNext.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { - appPreferences.updateData { - it.update { - signInAutomatically = AppPreference.SignInAuto.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - if (edgeThickness < 1) { - edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue + } } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { - appPreferences.updateData { - it.updateLiveTvPreferences { - showHeader = AppPreference.LiveTvShowHeader.defaultValue - favoriteChannelsAtBeginning = - AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue - sortByRecentlyWatched = - AppPreference.LiveTvChannelSortByWatched.defaultValue - colorCodePrograms = - AppPreference.LiveTvColorCodePrograms.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { - if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showClock = AppPreference.ShowClock.defaultValue + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { + PreferencesViewModel.resetSubtitleSettings(appPreferences) + } + if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + margin = SubtitleSettings.Margin.defaultValue.toInt() + } } } - } - } - // TODO temporarily disabled until some MPV bugs are fixed + if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { + appPreferences.updateData { + it.updateAdvancedPreferences { + if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { + imageDiskCacheSizeBytes = + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = AppPreference.MpvGpuNext.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { + appPreferences.updateData { + it.update { + signInAutomatically = AppPreference.SignInAuto.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + if (edgeThickness < 1) { + edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + } + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { + appPreferences.updateData { + it.updateLiveTvPreferences { + showHeader = AppPreference.LiveTvShowHeader.defaultValue + favoriteChannelsAtBeginning = + AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue + sortByRecentlyWatched = + AppPreference.LiveTvChannelSortByWatched.defaultValue + colorCodePrograms = + AppPreference.LiveTvColorCodePrograms.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { + if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } + } + + // TODO temporarily disabled until some MPV bugs are fixed // if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { // appPreferences.updateData { // it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } @@ -208,42 +263,248 @@ suspend fun upgradeApp( // showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) // } - if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - subtitlesPreferences = - subtitlesPreferences - .toBuilder() - .apply { - imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() - }.build() - // Copy current subtitle prefs as HDR ones - hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = + SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { - appPreferences.updateData { - it.updatePhotoPreferences { - slideshowDuration = AppPreference.SlideshowDuration.defaultValue + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { - appPreferences.updateData { - it.updateHomePagePreferences { - maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) { + seerrServerDao.getServers().forEach { + val server = it.server + seerrServerDao.updateServer( + server.copy(url = migrateSeerrUrl(server.url)), + ) + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) { + appPreferences.updateData { + it.updateMusicPreferences { + showBackdrop = true + showLyrics = true + showAlbumArt = true + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showLogos = AppPreference.ShowLogos.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) { + appPreferences.updateData { + 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 a64fc9d9..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,19 +18,25 @@ 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 import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Stores state for the backdrop of the app shown on non-full screen pages + * + * This is usually the backdrop of the currently focused media + */ @Singleton @OptIn(FlowPreview::class) class BackdropService @@ -45,19 +51,33 @@ class BackdropService private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE) val backdropFlow = _backdropFlow + /** + * Update the backdrop to use the specified item + */ suspend fun submit(item: BaseItem) = - withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + withContext(WholphinDispatchers.IO) { + val imageUrl = + if (item.type == BaseItemKind.GENRE) { + item.imageUrlOverride + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } submit(item.id.toString(), imageUrl) } + /** + * Update the backdrop to use the specified discovered item + */ suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) + /** + * Update the backdrop to use the specified URL + */ 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, @@ -68,6 +88,9 @@ class BackdropService } } + /** + * Remove the backdrop, such as when switching pages + */ suspend fun clearBackdrop() { _backdropFlow.update { BackdropResult.NONE @@ -87,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 { @@ -98,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 @@ -108,10 +131,10 @@ class BackdropService } } - private 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 @@ -218,6 +241,9 @@ class BackdropService } } +/** + * The result from determining the backdrop URL and extracted colors for the dynamic backdrop + */ data class BackdropResult( val itemId: String?, val imageUrl: String?, @@ -239,6 +265,9 @@ data class BackdropResult( } } +/** + * The colors extracted from an image + */ data class ExtractedColors( val primary: Color, val secondary: Color, 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 205e12bf..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 @@ -27,6 +29,9 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** + * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. + */ @Singleton class DatePlayedService @Inject @@ -51,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 = @@ -80,8 +85,14 @@ class DatePlayedService } } + /** + * Get the logical timestamp when the item was last played. + * + * This is calculated using lastest of the actual last played timestamp of the item, + * 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 @@ -93,6 +104,11 @@ class DatePlayedService } } + /** + * Remove the cached last date played for the given item's series + * + * Used for when a user plays this item + */ fun invalidate(item: BaseItem) { item.data.seriesId?.let { seriesId -> Timber.d("Invalidating %s", seriesId) @@ -100,6 +116,9 @@ class DatePlayedService } } + /** + * Remove the cached last data played for the given item or its series + */ suspend fun invalidate(itemId: UUID) { val seriesId = api.userLibraryApi.getItem(itemId = itemId).content.let { @@ -121,6 +140,9 @@ class DatePlayedService } } +/** + * Invalidates the date played cached when the current user changes + */ @ActivityScoped class DatePlayedInvalidationService @Inject @@ -132,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 976fb2c8..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,11 +1,19 @@ package com.github.damontecres.wholphin.services import android.content.Context -import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AssPlaybackMode +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 @@ -14,6 +22,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile import javax.inject.Inject import javax.inject.Singleton +/** + * Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer + */ @Singleton class DeviceProfileService @Inject @@ -21,7 +32,7 @@ class DeviceProfileService @param:ApplicationContext private val context: Context, ) { val mediaCodecCapabilitiesTest by lazy { - // Created lazily below on the IO thread since it cn take time + // Created lazily below on another thread since it cn take time MediaCodecCapabilitiesTest(context) } private val mutex = Mutex() @@ -30,20 +41,17 @@ class DeviceProfileService private var deviceProfile: DeviceProfile? = null suspend fun getOrCreateDeviceProfile( - prefs: PlaybackPreferences, + appPrefs: AppPreferences, serverVersion: ServerVersion?, ): DeviceProfile = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.Default) { + val prefs = appPrefs.playbackPreferences mutex.withLock { val newConfig = DeviceProfileConfiguration( maxBitrate = prefs.maxBitrate.toInt(), - isAC3Enabled = prefs.overrides.ac3Supported, - downMixAudio = prefs.overrides.downmixStereo, - assDirectPlay = prefs.overrides.directPlayAss, - 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), ) @@ -53,13 +61,28 @@ class DeviceProfileService createDeviceProfile( mediaTest = mediaCodecCapabilitiesTest, maxBitrate = newConfig.maxBitrate, - isAC3Enabled = newConfig.isAC3Enabled, - downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assDirectPlay, - 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!! @@ -72,11 +95,7 @@ class DeviceProfileService */ data class DeviceProfileConfiguration( val maxBitrate: Int, - val isAC3Enabled: Boolean, - val downMixAudio: Boolean, - val assDirectPlay: Boolean, - 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/DisplayPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt new file mode 100644 index 00000000..d5691fe6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.model.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DisplayPreferencesService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + ) { + private val mutex = Mutex() + + suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = client, + ).content + + suspend fun updateDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + block: MutableMap<String, String?>.() -> Unit, + ) { + mutex.withLock { + val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + block.invoke(this) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = client, + data = current.copy(customPrefs = customPrefs), + ) + } + } + + companion object { + const val DEFAULT_DISPLAY_PREF_ID = "default" + val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin" + } + } 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 e952177b..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 @@ -1,20 +1,37 @@ package com.github.damontecres.wholphin.services +import android.content.Context +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 import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ImageType import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Get extras for media + * + * @see [ExtrasItem] + */ @Singleton class ExtrasService @Inject constructor( private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val imageUrlService: ImageUrlService, ) { + /** + * Get the [ExtrasItem]s for the given item + */ suspend fun getExtras(itemId: UUID): List<ExtrasItem> { val extrasMap = api.userLibraryApi @@ -31,9 +48,47 @@ class ExtrasService extrasMap .mapNotNull { (type, items) -> if (items.size == 1) { - ExtrasItem.Single(itemId, type, items.first()) + val item = items.first() + val title = + item.title.takeIf { it.isNotNullOrBlank() } + ?: context.resources.getQuantityString(type.pluralRes, 1) + val subtitle = + if (item.title.isNotNullOrBlank()) { + context.resources.getQuantityString(type.pluralRes, 1) + } else { + null + } + ExtrasItem.Single( + parentId = itemId, + type = type, + item = item, + title = title, + subtitle = subtitle, + imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), + ) } else if (items.size > 1) { - ExtrasItem.Group(itemId, type, items) + 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, + items = items, + title = title, + subtitle = subtitle, + isPlayed = items.all { it.played }, + imageUrl = + imageUrlService.getItemImageUrl( + itemForImage, + ImageType.PRIMARY, + ), + aspectRatio = itemForImage.aspectRatio, + ) } else { null } @@ -42,6 +97,9 @@ class ExtrasService } } +/** + * The order which extras should be shown + */ private val ExtraType.sortOrder: Int get() = when (this) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index a68a6fe5..93b3ebe4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.data.model.BaseItem import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -8,6 +9,9 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Handles toggling media as favorited or watched + */ @Singleton class FavoriteWatchManager @Inject @@ -36,4 +40,7 @@ class FavoriteWatchManager } else { api.userLibraryApi.unmarkFavoriteItem(itemId).content } + + suspend fun removeContinueWatching(item: BaseItem) { + } } 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 new file mode 100644 index 00000000..1c05640e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -0,0 +1,716 @@ +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.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.preferences.DefaultUserConfiguration +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.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.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.intOrNull +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.userApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.CollectionType +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 + */ +@Singleton +class HomeSettingsService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val displayPreferencesService: DisplayPreferencesService, + private val serverPluginApi: ServerPluginApi, + private val serverDao: JellyfinServerDao, + ) { + /** + * The current home page settings + */ + val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) + + /** + * Saves a [HomePageSettings] to the server for the user under the display preference ID + * + * @see loadFromServer + */ + suspend fun saveToServer( + userId: UUID, + settings: HomePageSettings, + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, + ) { + displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } + } + + /** + * Reads a [HomePageSettings] from the server for the user and display preference ID + * + * Returns null if there is none saved + * + * @see saveToServer + */ + suspend fun loadFromServer( + userId: UUID, + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, + ): HomePageSettings? = + displayPreferencesService + .getDisplayPreferences(userId, displayPreferencesId) + .customPrefs[CUSTOM_PREF_ID] + ?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } + + /** + * Computes the filename for locally saved [HomePageSettings] + */ + private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json" + + /** + * Save the [HomePageSettings] for the user locally on the device + * + * @see loadFromLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun saveToLocal( + userId: UUID, + settings: HomePageSettings, + ) { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + dir.mkdirs() + File(dir, filename(userId)).outputStream().use { + jsonParser.encodeToStream(settings, it) + } + } + + /** + * Reads [HomePageSettings] for the user if it exists + * + * @see saveToLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun loadFromLocal(userId: UUID): HomePageSettings? { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + val file = File(dir, filename(userId)) + return if (file.exists()) { + val fileContents = file.readText() + val jsonElement = jsonParser.parseToJsonElement(fileContents) + decode(jsonElement) + } else { + null + } + } + + /** + * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows + */ + @VisibleForTesting + fun decode(element: JsonElement): HomePageSettings { + val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull + if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { + throw UnsupportedHomeSettingsVersionException(version) + } + val rowsElement = element.jsonObject["rows"]?.jsonArray + val rows = + rowsElement + ?.mapNotNull { row -> + try { + jsonParser.decodeFromJsonElement<HomeRowConfig>(row) + } catch (ex: Exception) { + Timber.w(ex, "Unknown row %s", row) + // TODO maybe use placeholder instead of null? + null + } + }.orEmpty() + 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] + * + * First checks locally, then on the server, and finally creates a default if needed + * + * Does not persist either the server nor default + */ + suspend fun loadCurrentSettings(user: JellyfinUser) { + currentSettings.update { HomePageResolvedSettings.EMPTY } + + Timber.v("Getting setting for %s", user.id) + val settings = + try { + fetchSettingsFor(user.id, user.config.homeSettingsSource) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) + null + } + 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( + source: HomePageSettingsSource, + settings: HomePageSettings, + ) { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + currentSettings.update { it.copy(rows = resolvedRows) } + serverRepository.currentUser?.let { user -> + val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } + serverDao.updateUser(toSave) + } + } + + /** + * Create a default [HomePageResolvedSettings] using the available libraries + */ + suspend fun createDefault(userId: UUID): HomePageResolvedSettings { + Timber.v("Creating default settings") + 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) + } else { + navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) + } + + val includedIds = + libraries + .mapIndexed { index, library -> + val parentId = library.itemId + val title = getRecentlyAddedTitle(library.name) + if (library.collectionType == CollectionType.LIVETV) { + HomeRowConfigDisplay( + id = index, + title = ResStringProvider(R.string.watch_live), + config = HomeRowConfig.TvPrograms(), + ) + } else { + val viewOptions = viewOptionsForCollectionType(library.collectionType) + HomeRowConfigDisplay( + id = index, + title = title, + config = + HomeRowConfig.RecentlyAdded( + parentId = parentId, + viewOptions = viewOptions, + ), + ) + } + } + 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) + } + + /** + * Create home page settings from the user's web UI home page settings + */ + suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { + val customPrefs = + displayPreferencesService + .getDisplayPreferences( + displayPreferencesId = "usersettings", + userId = userId, + client = "emby", + ).customPrefs + val userDto by api.userApi.getUserById(userId) + val config = userDto.configuration ?: DefaultUserConfiguration + val libraries = + navDrawerService + .getAllUserLibraries(userId, userDto.tvAccess) + .filterNot { + it.itemId in config.latestItemsExcludes + } + + return if (customPrefs.isNotEmpty()) { + var id = 0 + val rowConfigs = + (0..9) + .mapNotNull { idx -> + val sectionType = + HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase()) + Timber.v( + "sectionType=$sectionType, %s", + customPrefs["homesection$idx"]?.lowercase(), + ) + val config = + when (sectionType) { + HomeSectionType.ACTIVE_RECORDINGS -> { + HomeRowConfigDisplay( + id = id++, + title = ResStringProvider(R.string.active_recordings), + config = HomeRowConfig.Recordings(), + ) + } + + HomeSectionType.RESUME -> { + HomeRowConfigDisplay( + id = id++, + title = ResStringProvider(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ) + } + + HomeSectionType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id++, + title = ResStringProvider(R.string.next_up), + config = HomeRowConfig.NextUp(), + ) + } + + HomeSectionType.LIVE_TV -> { + if (userDto.tvAccess) { + HomeRowConfigDisplay( + id = id++, + title = ResStringProvider(R.string.watch_live), + config = HomeRowConfig.TvPrograms(), + ) + } else { + null + } + } + + HomeSectionType.LATEST_MEDIA -> { + // Handled below + null + } + + // Unsupported + HomeSectionType.RESUME_AUDIO, + HomeSectionType.RESUME_BOOK, + -> { + null + } + + HomeSectionType.SMALL_LIBRARY_TILES, + HomeSectionType.LIBRARY_BUTTONS, + HomeSectionType.NONE, + null, + -> { + null + } + } + if (sectionType == HomeSectionType.LATEST_MEDIA) { + libraries.map { library -> + val viewOptions = + viewOptionsForCollectionType(library.collectionType) + HomeRowConfigDisplay( + id = id++, + title = + ResArgStringProvider( + R.string.recently_added_in, + library.name ?: "", + ), + config = + HomeRowConfig.RecentlyAdded( + parentId = library.itemId, + viewOptions = viewOptions, + ), + ) + } + } else if (config != null) { + listOf(config) + } else { + null + } + }.flatten() + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) + } else { + null + } + } + + /** + * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes + */ + suspend fun resolve( + id: Int, + config: HomeRowConfig, + ): HomeRowConfigDisplay = + when (config) { + is HomeRowConfig.ByParent -> { + val name = getItemName(null, config.parentId) + HomeRowConfigDisplay( + id, + name, + config, + ) + } + + is HomeRowConfig.ContinueWatching -> { + HomeRowConfigDisplay( + id, + ResStringProvider(R.string.continue_watching), + config, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + HomeRowConfigDisplay( + id, + ResStringProvider(R.string.combine_continue_next), + config, + ) + } + + is HomeRowConfig.Genres -> { + val title = getItemName(R.string.genres_in, config.parentId) + HomeRowConfigDisplay( + id, + title, + config, + ) + } + + is HomeRowConfig.Studios -> { + val title = getItemName(R.string.studios_in, config.parentId) + HomeRowConfigDisplay( + id, + title, + config, + ) + } + + is HomeRowConfig.GetItems -> { + HomeRowConfigDisplay(id, StringStringProvider(config.name), config) + } + + is HomeRowConfig.NextUp -> { + HomeRowConfigDisplay( + id, + ResStringProvider(R.string.next_up), + config, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val title = getItemName(R.string.recently_added_in, config.parentId) + HomeRowConfigDisplay( + id, + title, + config, + ) + } + + is HomeRowConfig.RecentlyReleased -> { + val title = getItemName(R.string.recently_released_in, config.parentId) + HomeRowConfigDisplay( + id, + title, + config, + ) + } + + is HomeRowConfig.Favorite -> { + val name = + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(config.kind)), + ) + HomeRowConfigDisplay(id, name, config) + } + + is HomeRowConfig.Recordings -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.active_recordings), + config, + ) + } + + is HomeRowConfig.TvPrograms -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.watch_live), + config, + ) + } + + is HomeRowConfig.TvChannels -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.channels), + config, + ) + } + + is HomeRowConfig.Suggestions -> { + val title = getItemName(R.string.suggestions_for, config.parentId) + HomeRowConfigDisplay( + id = id, + title = title, + config, + ) + } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(config.title), + config, + ) + } + } + + private suspend fun getItemName( + @StringRes stringRes: Int?, + itemId: UUID, + default: StringProvider = StringStringProvider(""), + ): StringProvider = + try { + api.userLibraryApi + .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) + 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 + } + } + } + +/** + * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI + */ +data class HomeRowConfigDisplay( + val id: Int, + val title: StringProvider, + val config: HomeRowConfig, +) + +/** + * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s + * + * @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(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList()) + } +} + +// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs +enum class HomeSectionType( + val serialName: String, +) { + NONE("none"), + SMALL_LIBRARY_TILES("smalllibrarytitles"), + LIBRARY_BUTTONS("librarybuttons"), + ACTIVE_RECORDINGS("activerecordings"), + RESUME("resume"), + RESUME_AUDIO("resumeaudio"), + LATEST_MEDIA("latestmedia"), + NEXT_UP("nextup"), + LIVE_TV("livetv"), + RESUME_BOOK("resumebook"), + ; + + companion object { + fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } } + } +} + +class UnsupportedHomeSettingsVersionException( + val unsupportedVersion: Int?, + val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, +) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") + +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 39967e24..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 @@ -28,21 +28,28 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, - imageTags: Map<ImageType, String?>, imageType: ImageType, + imageTags: Map<ImageType, String?>, + backdropTags: List<String>, + parentThumbId: UUID? = null, + 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.BACKDROP, - ImageType.LOGO, - -> { + ImageType.LOGO -> { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { getItemImageUrl( itemId = seriesId, imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentLogoTag.takeIf { parentLogoId == seriesId }, ) } else { getItemImageUrl( @@ -50,12 +57,99 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], + ) + } + } + + ImageType.BACKDROP, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, + ) + } else if (backdropTags.isNotEmpty()) { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = backdropTags.first(), + ) + } else { + null + } + } + + ImageType.THUMB -> { + if (useSeriesForPrimary && parentThumbId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // Use parent's thumb + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = parentThumbTag, + ) + } else if (useSeriesForPrimary && parentBackdropId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // No parent thumb, so use backdrop instead + getItemImageUrl( + itemId = parentBackdropId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull(), + ) + } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = parentThumbTag, + ) + } else if (useSeriesForPrimary && + parentThumbId == null && + itemType == BaseItemKind.EPISODE && + imageType !in imageTags + ) { + // Workaround to fall back to episode image if no parent thumb + getItemImageUrl( + itemId = itemId, + 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 + getItemImageUrl( + itemId = itemId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = backdropTags.first(), + ) + } else { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = imageTags[imageType], ) } } ImageType.PRIMARY, - ImageType.THUMB, ImageType.BANNER, -> { if (useSeriesForPrimary && seriesId != null && @@ -66,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( @@ -73,6 +168,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else { getItemImageUrl( @@ -80,6 +176,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -90,6 +187,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -99,17 +197,26 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, + useSeriesForPrimary: Boolean? = null, ): String? = if (item != null) { getItemImageUrl( itemId = item.id, itemType = item.type, seriesId = item.data.seriesId, - useSeriesForPrimary = item.useSeriesForPrimary, + useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary, imageTags = item.data.imageTags.orEmpty(), imageType = imageType, + parentThumbId = item.data.parentThumbItemId, + parentBackdropId = item.data.parentBackdropItemId, + 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 @@ -140,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/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt new file mode 100644 index 00000000..543ea656 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Gets/saves a serializable object by name + */ +@Singleton +class KeyValueService + @Inject + constructor( + val dataStore: DataStore<Preferences>, + ) { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + inline fun <reified T> get(key: String): Flow<T?> = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString<T>(serializer<T>(), it) + } + } + + inline fun <reified T> get( + key: String, + defaultValue: T, + ): Flow<T> = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString<T>(serializer<T>(), it) + } ?: defaultValue + } + + inline fun <reified T> get( + userId: UUID, + key: String, + defaultValue: T, + ): Flow<T> = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey("${userId}_$key")]?.let { + json.decodeFromString<T>(serializer<T>(), it) + } ?: defaultValue + } + + suspend inline fun <reified T> save( + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey(key), valueStr) + } + } + } + + suspend inline fun <reified T> save( + userId: UUID, + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey("${userId}_$key"), valueStr) + } + } + } + } 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 6ac016a3..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 @@ -1,30 +1,32 @@ +@file:UseSerializers( + UUIDSerializer::class, + LocalDateTimeSerializer::class, +) + package com.github.damontecres.wholphin.services -import android.content.Context -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.main.LatestData -import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.supportItemKinds -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.ui.HomeSlimItemFields +import com.github.damontecres.wholphin.util.LocalDateTimeSerializer +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +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.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +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 +import org.jellyfin.sdk.model.serializer.UUIDSerializer import timber.log.Timber import java.time.LocalDateTime import java.util.UUID @@ -32,57 +34,63 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds +/** + * Get continue watching and next up items for users + */ @Singleton class LatestNextUpService @Inject constructor( - @param:ApplicationContext private val context: Context, private val api: ApiClient, private val datePlayedService: DatePlayedService, + private val displayPreferencesService: DisplayPreferencesService, + private val favoriteWatchManager: FavoriteWatchManager, ) { + /** + * Get resume (continue watching) items for a user + */ suspend fun getResume( userId: UUID, limit: Int, includeEpisodes: Boolean, + useSeriesForPrimary: Boolean = true, ): List<BaseItem> { 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, true) } + .map { BaseItem(it, useSeriesForPrimary) } return items } + /** + * Get next up items for a user + */ suspend fun getNextUp( userId: UUID, limit: Int, enableRewatching: Boolean, enableResumable: Boolean, maxDays: Int, + useSeriesForPrimary: Boolean = true, ): List<BaseItem> { + val removedSeries = getRemovedFromNextUp(userId) val nextUpDateCutoff = maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = GetNextUpRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, imageTypeLimit = 1, parentId = null, limit = limit, @@ -90,87 +98,51 @@ class LatestNextUpService enableUserData = true, enableRewatching = enableRewatching, nextUpDateCutoff = nextUpDateCutoff, + enableTotalRecordCount = false, ) val nextUp = api.tvShowsApi .getNextUp(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem(it, useSeriesForPrimary) } + .filter { + val seriesId = it.data.seriesId + if (seriesId != null && seriesId in removedSeries) { + // User has previously removed the series + val lastPlayedDate = it.data.userData?.lastPlayedDate + if (lastPlayedDate != null) { + // If item played it after it was removed, should include it + lastPlayedDate > removedSeries[seriesId] + } else { + // If unknown last played, filter out + false + } + } else { + true + } + } + return nextUp } - suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List<UUID>, - ): List<LatestData> { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - return rows - } - + /** + * Create the combined Continue Watching & Next Up items + * + * @see [DatePlayedService] + */ suspend fun buildCombined( 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) @@ -191,4 +163,112 @@ class LatestNextUpService Timber.v("buildCombined took %s", duration) return@withContext result } + + /** + * Remove a series from next up + */ + suspend fun removeFromNextUp( + userId: UUID, + episode: BaseItem, + ) { + favoriteWatchManager.setWatched(episode.id, false) + episode.data.seriesId?.let { seriesId -> + displayPreferencesService.updateDisplayPreferences(userId) { + val removedIds = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString<RemovedSeriesIds>(it).value + }.orEmpty() + .toMutableMap() + removedIds[seriesId] = LocalDateTime.now() + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(removedIds)), + ) + } + } + } + + /** + * Get when series were removed from next up + */ + suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> = + displayPreferencesService + .getDisplayPreferences(userId) + .customPrefs[REMOVED_KEY] + ?.let { + Json.decodeFromString<RemovedSeriesIds>(it).value + }.orEmpty() + + suspend fun allowSeriesRemovedFromNextUp( + userId: UUID, + seriesId: UUID, + ) { + displayPreferencesService.updateDisplayPreferences(userId) { + val ids = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString<RemovedSeriesIds>(it).value + }.orEmpty() + .toMutableMap() + ids.remove(seriesId) + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(ids)), + ) + } + } + + /** + * Check if user has watched a series since removing it + */ + suspend fun updateRemovedFromNextUp(userId: UUID) { + val removed = getRemovedFromNextUp(userId) + val newRemoved = removed.toMutableMap() + var changed = false + removed.forEach { (seriesId, timestamp) -> + val item = + api.itemsApi + .getItems( + userId = userId, + parentId = seriesId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_PLAYED), + sortOrder = listOf(SortOrder.DESCENDING), + limit = 1, + ).content.items + .firstOrNull() + if (item != null) { + val lastPlayed = item.userData?.lastPlayedDate + if (lastPlayed != null && lastPlayed > timestamp) { + Timber.v("Updating removed next up for series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } else { + // Series doesn't exist anymore + Timber.v("Updating removed next up for missing series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } + if (changed) { + displayPreferencesService.updateDisplayPreferences(userId) { + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(newRemoved)), + ) + } + } + } + + companion object { + const val REMOVED_KEY = "removeNextUp" + } } + +@Serializable +data class RemovedSeriesIds( + val value: Map<UUID, LocalDateTime>, +) 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 new file mode 100644 index 00000000..a60bf7c4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -0,0 +1,138 @@ +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 +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +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.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@HiltWorker +class LatestNextUpWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + ) : 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() + + try { + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { + Timber.w("No user found during run") + return Result.failure() + } + } + latestNextUpService.updateRemovedFromNextUp(userId) + return Result.success() + } catch (ex: Exception) { + Timber.e(ex, "Error during updateRemovedFromNextUp") + return Result.retry() + } + } + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +@ActivityScoped +class LatestNextUpSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val workManager: WorkManager, + ) { + private val activity = + (context as? AppCompatActivity) + ?: throw IllegalStateException( + "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", + ) + + @VisibleForTesting + internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO + + init { + serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } + } + } + } + + private suspend fun scheduleWork( + userId: UUID, + serverId: UUID, + ) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder<LatestNextUpWorker>( + repeatInterval = 4.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setConstraints(constraints) + .setInputData( + workDataOf( + LatestNextUpWorker.PARAM_USER_ID to userId.toString(), + LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(), + ), + ) + Timber.i("Scheduling periodic LatestNextUpWorker") + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = LatestNextUpWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE, + request = periodicWorkRequestBuilder.build(), + ) + } + } 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 new file mode 100644 index 00000000..684d56b0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.combinePair +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service to manage media such as deletions + */ +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow<DeletedItem>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + val appPreferences = userPreferencesService.getCurrent().appPreferences + return canDelete(item, appPreferences) + } + + /** + * Check if the item can be deleted. This means the app setting is enabled and the user has permission. + */ + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun collectCanDelete( + itemFlow: Flow<BaseItem?>, + update: (Boolean) -> Unit, + ) { + itemFlow.combinePair(userPreferencesService.flow).collectLatest { (item, prefs) -> + val canDelete = item?.let { canDelete(item, prefs.appPreferences) } ?: false + update.invoke(canDelete) + } + } + + /** + * Delete the item. + * + * This item will be sent through [deletedItemFlow] for other services or view models to react. + */ + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +/** + * Convenience function to delete an item and show a Toast based on success or error + */ +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} 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 new file mode 100644 index 00000000..16ac4edd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -0,0 +1,605 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +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.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 +import com.github.damontecres.wholphin.ui.seekForward +import com.github.damontecres.wholphin.ui.toServerString +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.Job +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.runBlocking +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.instantMixApi +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 +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +/** + * Manage the global state for playing music + * + * Has functions for modifying the queue + */ +@OptIn(UnstableApi::class) +@Singleton +class MusicService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val defaultScope: CoroutineScope, + private val api: ApiClient, + 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 + + private val audioFormats by lazy { listOf(*supportedAudioCodecs) } + + val player: Player by lazy { + val disableAudioOffload = + runBlocking { + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .get { disableAudioOffload } ?: false + } + playerFactory + .createAudioPlayer(disableAudioOffload = disableAudioOffload) + .also { + it.addListener(MusicPlayerListener(it, _state)) + } + } + + private val mutex = Mutex() + var mediaSession: MediaSession? = null + private set + private var activityTracker: TrackActivityPlaybackListener? = null + private var websocketJob: Job? = null + + private suspend fun preferAc3Surround() = + userPreferencesService + .getCurrent() + .appPreferences.experimentalPreferences + .enabled { preferAc3Surround } + + /** + * Start music playback + * + * Sets up the media session, activity tracking, and actual playback + */ + suspend fun start() { + if (mediaSession == null) { + mutex.withLock { + if (mediaSession == null) { + Timber.i("Starting music MediaSession") + mediaSession = + MediaSession + .Builder(context, player) + .setCallback(TrustAllControllersCallback) + .build() + activityTracker = + TrackActivityPlaybackListener(api, player) { + state.value.currentItemId?.let { itemId -> + PlaybackItemState( + itemId = itemId, + playMethod = PlayMethod.DIRECT_PLAY, +// playSessionId = mediaSession?.id, + ) + } + }.also { player.addListener(it) } + websocketJob = subscribe() + } + } + } + onMain { + player.prepare() + player.play() + } + } + + /** + * Stop music playback + */ + suspend fun stop() { + mutex.withLock { + Timber.i("Stopping music") + if (mediaSession == null) { + Timber.w("Stopping but no MediaSession") + } + mediaSession?.release() + mediaSession = null + onMain { + websocketJob?.cancel() + websocketJob = null + activityTracker?.let { + it.release() + player.removeListener(it) + } + activityTracker = null + player.stop() + player.setMediaItems(emptyList()) + } + _state.update { + MusicServiceState.EMPTY + } + } + } + + /** + * Fetches instant mix items, replaces the queue, and begins playback + */ + suspend fun startInstantMix(itemId: UUID) = + loading { + val items = + api.instantMixApi + .getInstantMixFromItem( + userId = serverRepository.currentUser?.id, + itemId = itemId, + limit = 200, + fields = DetailItemFields, + ).content.items + .map { BaseItem(it, false) } + setQueue(items, false) + } + + /** + * Replace the queue with the given list and starting playing the song as startIndex as soon as its ready + * + * Fetches each item in a blocking way and adds to the queue + */ + suspend fun setQueue( + items: BlockingList<BaseItem?>, + startIndex: Int, + shuffled: Boolean, + ) = withContext(WholphinDispatchers.IO) { + Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) + withContext(WholphinDispatchers.Main) { + player.setMediaItems(emptyList()) + player.shuffleModeEnabled = shuffled + } + start() + addAllToQueue(items, startIndex) + } + + /** + * Replace the queue with the given items + */ + suspend fun setQueue( + items: List<BaseItem>, + 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(it, preferAc3Surround) } + withContext(WholphinDispatchers.Main) { + player.setMediaItems(mediaItems) + player.shuffleModeEnabled = shuffled + updateQueueSize() + start() + } + } + + /** + * Add an item to the specified index of the queue. If no index specified, it will be added to the end. + */ + suspend fun addToQueue( + item: BaseItem, + index: Int? = null, + ) { + if (item.type == BaseItemKind.AUDIO) { + val preferAc3Surround = preferAc3Surround() + val mediaItem = convert(item, preferAc3Surround) + withContext(WholphinDispatchers.Main) { + if (index != null) { + player.addMediaItem(index, mediaItem) + } else { + player.addMediaItem(mediaItem) + } + updateQueueSize() + if (player.mediaItemCount == 1) { + // Start playing if this was the first time added + start() + } + } + } else { + Timber.w("Tried to add non-audio type %s: %s", item.type, item.id) + } + } + + /** + * Add all the items in teh list to end of the queue + * + * @param startIndex The index to start from within the source list + */ + suspend fun addAllToQueue( + list: BlockingList<BaseItem?>, + startIndex: Int, + ) = loading { + val preferAc3Surround = preferAc3Surround() + var remaining = startIndex + list.indices + .chunked(25) + .forEach { + val mediaItems = + it.mapNotNull { + if (remaining == 0) { + list + .getBlocking(it) + ?.takeIf { it.type == BaseItemKind.AUDIO } + ?.let { convert(it, preferAc3Surround) } + } else { + Timber.v("Skipping $remaining") + remaining-- + null + } + } + onMain { player.addMediaItems(mediaItems) } + } + updateQueueSize() + start() + } + + /** + * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag + */ + 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 = + 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 + .Builder() + .setUri(url) + .setMediaId(audio.id.toServerString()) + .setTag(AudioItem.from(audio, imageUrl)) + .build() + } + + /** + * Updates the state for when the queue changes + */ + private suspend fun updateQueueSize() { +// val ids = +// withContext(WholphinDispatchers.Default) { +// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() } +// } + val timeline = onMain { player.currentTimeline } + val window = Timeline.Window() + val ids = + (0..<timeline.windowCount) + .map { + timeline.getWindow(it, window) + window.mediaItem.mediaId.toUUID() + }.toSet() + withContext(WholphinDispatchers.Main) { + _state.update { + it.copy( + queueVersion = it.queueVersion + 1, + queueSize = player.mediaItemCount, + queuedIds = ids, + ) + } + } + } + + /** + * Move an item within the queue + */ + suspend fun moveQueue( + index: Int, + direction: MoveDirection, + ) = withContext(WholphinDispatchers.Main) { + player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) + updateQueueSize() + } + + /** + * Move an item within the queue + */ + suspend fun moveQueue( + index: Int, + newIndex: Int, + ) = withContext(WholphinDispatchers.Main) { + player.moveMediaItem(index, newIndex) + updateQueueSize() + } + + /** + * Start playback at the given index of the queue + */ + suspend fun playIndex(index: Int) { + onMain { + player.seekTo(index, 0L) + player.play() + } + // MusicPlayerListener will update state + } + + /** + * 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, preferAc3Surround()) + onMain { + player.addMediaItem(state.value.currentIndex + 1, mediaItem) + if (player.mediaItemCount == 1) { + start() + } + } + updateQueueSize() + } + + /** + * From the item at the given index from the queue + */ + suspend fun removeFromQueue(index: Int) { + onMain { player.removeMediaItem(index) } + updateQueueSize() + } + + private suspend fun <T> loading(block: suspend () -> T): T { + _state.update { it.copy(loadingState = LoadingState.Loading) } + val result = block.invoke() + _state.update { it.copy(loadingState = LoadingState.Success) } + return result + } + + /** + * Subscribes to the server websocket to receive playback commands + */ + private fun subscribe(): Job = + api.webSocket + .subscribe<PlaystateMessage>() + .onEach { message -> + message.data?.let { + withContext(WholphinDispatchers.Main) { + when (it.command) { + PlaystateCommand.STOP -> { + stop() + } + + PlaystateCommand.PAUSE -> { + player.pause() + } + + PlaystateCommand.UNPAUSE -> { + player.play() + } + + PlaystateCommand.NEXT_TRACK -> { + player.seekToNext() + } + + PlaystateCommand.PREVIOUS_TRACK -> { + player.seekToPrevious() + } + + PlaystateCommand.SEEK -> { + it.seekPositionTicks?.ticks?.let { + player.seekTo( + it.inWholeMilliseconds, + ) + } + } + + PlaystateCommand.REWIND -> { + player.seekBack(10.seconds) + } + + PlaystateCommand.FAST_FORWARD -> { + player.seekForward(30.seconds) + } + + PlaystateCommand.PLAY_PAUSE -> { + if (player.isPlaying) player.pause() else player.play() + } + } + } + } + }.catch { ex -> + Timber.e(ex, "Error in websocket subscription") + }.launchIn(defaultScope) + } + +@Stable +data class MusicServiceState( + val queueVersion: Long, + val queueSize: Int, + val currentIndex: Int, + val currentItemId: UUID?, + val status: NowPlayingStatus, + val currentItemTitle: String?, + val loadingState: LoadingState = LoadingState.Pending, + val queuedIds: Set<UUID>, +) { + companion object { + val EMPTY = + MusicServiceState( + 0L, + 0, + 0, + null, + NowPlayingStatus.IDLE, + null, + LoadingState.Pending, + emptySet(), + ) + } +} + +enum class NowPlayingStatus { + PLAYING, + PAUSED, + IDLE, +} + +/** + * Listens to [Player] events and updates the [StateFlow] + */ +private class MusicPlayerListener( + private val player: Player, + private val state: MutableStateFlow<MusicServiceState>, +) : Player.Listener { + init { + Timber.v("MusicPlayerListener init") + state.update { + it.copy( + queueSize = player.mediaItemCount, + currentIndex = player.currentMediaItemIndex, + status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE, + ) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Timber.v("MusicPlayerListener onIsPlayingChanged") + state.update { + it.copy( + status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED, + ) + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + Timber.v("MusicPlayerListener onMediaItemTransition") + updateCurrentIndex() + } + + override fun onTimelineChanged( + timeline: Timeline, + reason: Int, + ) { +// Timber.v("MusicPlayerListener onTimelineChanged") + if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + updateCurrentIndex() + } + } + + private fun updateCurrentIndex() { + state.update { state -> + player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex -> + if (currentMediaItemIndex in (0..<player.mediaItemCount)) { + val item = + player.getMediaItemAt(currentMediaItemIndex).localConfiguration?.tag as? AudioItem + state.copy( + currentIndex = currentMediaItemIndex, + currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(), + currentItemTitle = item?.title, + ) + } else { + state + } + } ?: state + } + } +} + +/** + * Remember the queue currently playing + * + * @see MusicServiceState + */ +@Composable +fun rememberQueue( + player: Player, + queueVersion: Long, + queueSize: Int, +): List<AudioItem> = + remember(queueVersion, queueSize) { + object : AbstractList<AudioItem>() { + override val size: Int + get() = player.mediaItemCount + + override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem + } + } 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 new file mode 100644 index 00000000..ea45cd75 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -0,0 +1,263 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +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 +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +/** + * Gets the items to show in the nav drawer + */ +@Singleton +class NavDrawerService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + private val musicService: MusicService, + ) { + private val _state = MutableStateFlow(NavDrawerItemState()) + val state: StateFlow<NavDrawerItemState> = _state + + init { + // Handle updating the nav drawer when the user changes + 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, discoverActive) + } else { + _state.update { NavDrawerItemState() } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating nav drawer") + showToast(context, "Error fetching user's views") + } + } + + // Handle when music is actively playing or not + coroutineScope.launchDefault { + musicService.state.collectLatest { music -> + Timber.v("MusicService updated") + when (music.status) { + NowPlayingStatus.PLAYING -> { + _state.update { + it.copy( + nowPlayingEnabled = true, + nowPlayingTitle = music.currentItemTitle, + ) + } + } + + NowPlayingStatus.IDLE -> { + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + + NowPlayingStatus.PAUSED -> { + delay(2.hours) + _state.update { + it.copy( + nowPlayingEnabled = false, + nowPlayingTitle = null, + ) + } + } + } + } + } + } + + /** + * Get all the libraries the user has access to + */ + suspend fun getAllUserLibraries( + userId: UUID, + tvAccess: Boolean, + ): List<Library> { + val userViews = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + val recordingFolders = + if (tvAccess) { + 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 { + emptySet() + } + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + Library( + itemId = it.id, + name = it.name ?: "", + type = it.type, + collectionType = it.collectionType ?: CollectionType.UNKNOWN, + isRecordingFolder = it.id in recordingFolders, + ) + } + return libraries + } + + /** + * Get the libraries that the user has not "pinned". These will show in the More section. + */ + suspend fun getFilteredUserLibraries( + user: JellyfinUser, + tvAccess: Boolean, + ): List<Library> { + val pins = + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + val libraries = + getAllUserLibraries(user.id, tvAccess) + .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } + return libraries + } + + /** + * Update the current state of the nav drawer items + */ + suspend fun updateNavDrawer( + user: JellyfinUser, + userDto: ServerUserConfig, + discoverActive: Boolean, + ) { + val builtins = + buildList { + add(NavDrawerItem.Favorites) + if (discoverActive) add(NavDrawerItem.Discover) + } + val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) + val libraries = + allLibraries + .map { + val destination = + if (it.isRecordingFolder) { + Destination.Recordings(it.itemId) + } else { + Destination.MediaItem( + it.itemId, + it.type, + it.collectionType, + ) + } + ServerNavDrawerItem( + itemId = it.itemId, + name = it.name, + destination = destination, + type = it.collectionType, + ) + } + val allItems = builtins + libraries + + val navDrawerPins = + withContext(WholphinDispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } + + val items = mutableListOf<NavDrawerItem>() + val moreItems = mutableListOf<NavDrawerItem>() + allItems + // Sort by order if non-default, existing items before customize will have -1 value + // New items from the server will get Int.MAX_VALUE + // Items the user doesn't have access to anymore will be skipped + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .forEach { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + if (pinned == NavPinType.PINNED) { + items.add(it) + } else { + moreItems.add(it) + } + } + + _state.update { + it.copy( + items = items, + moreItems = moreItems, + allLibraries = allLibraries, + ) + } + } + } + +data class NavDrawerItemState( + 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 a1754298..3208f4ec 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 @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.services -import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -14,7 +14,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList<NavKey> = mutableListOf() + var backStack: MutableList<Destination> = NavBackStack(Destination.Home()) /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index b4967b03..1df73d18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,17 +1,23 @@ package com.github.damontecres.wholphin.services +import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty +import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject import javax.inject.Singleton +/** + * Gets people in media, specifically to check if they are favorited or not + */ @Singleton class PeopleFavorites @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -27,7 +33,14 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } + people.map { + Person.fromDto( + context, + it, + favorites[it.id] ?: false, + api, + ) + } }.orEmpty() people } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 4be082a5..91c1e7cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject @@ -20,13 +19,6 @@ class PlaybackLifecycleObserver private var wasPlaying: Boolean? = null override fun onStart(owner: LifecycleOwner) { - val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || - lastDest is Destination.PlaybackList || - lastDest is Destination.Slideshow - ) { - navigationManager.goBack() - } wasPlaying = null } 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 425ab94d..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 @@ -6,27 +6,50 @@ import android.content.Context import android.os.Build import android.os.Handler import androidx.annotation.OptIn -import androidx.datastore.core.DataStore +import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences +import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener -import com.github.damontecres.wholphin.preferences.AppPreference +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.util.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.get +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking +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.withContext +import okhttp3.OkHttpClient import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject @@ -40,77 +63,25 @@ class PlayerFactory @Inject constructor( @param:ApplicationContext private val context: Context, - private val appPreferences: DataStore<AppPreferences>, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null private set - fun createVideoPlayer(): Player { - if (currentPlayer?.isReleased == false) { - Timber.w("Player was not released before trying to create a new one!") - currentPlayer?.release() - } - - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue - val newPlayer = - when (backend) { - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - val useGpuNext = - prefs?.mpvOptions?.useGpuNext - ?: AppPreference.MpvGpuNext.defaultValue - MpvPlayer(context, enableHardwareDecoding, useGpuNext) - .apply { - playWhenReady = true - } - } - - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - val decodeAv1 = prefs?.overrides?.decodeAv1 == true - Timber.v("extensions=$extensions") - val rendererMode = - when (extensions) { - MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - } - ExoPlayer - .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() - .apply { - playWhenReady = true - } - } - } - currentPlayer = newPlayer - return newPlayer - } - suspend fun createVideoPlayer( backend: PlayerBackend, - prefs: PlaybackPreferences, - ): Player { - withContext(Dispatchers.Main) { + appPreferences: AppPreferences, + ): PlayerCreation { + 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() } } - + var assHandler: AssHandler? = null val newPlayer = when (backend) { PlayerBackend.PREFER_MPV, @@ -125,8 +96,23 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled + val useLibAss = + prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions") + val preferDolbyVision = + appPreferences.experimentalPreferences + .get { preferDolbyVisionOverHdr10Plus } ?: false + val convertDolbyVisionProfile7 = + DolbyVisionProfile7Conversion.isWanted( + appPreferences.experimentalPreferences, + deviceProfileService.mediaCodecCapabilitiesTest, + ) + Timber.v( + "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", + extensions, + prefs.overrides.assPlaybackMode, + convertDolbyVisionProfile7, + ) val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -134,20 +120,180 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } + 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, + preferDolbyVision, + convertDolbyVisionProfile7, + ).setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + + val mediaSourceFactory = + if (useLibAss) { + val renderType = + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + AssRenderType.OVERLAY_CANVAS + } else { + AssRenderType.OVERLAY_OPEN_GL + } + assHandler = AssHandler(renderType) + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + renderersFactory = AssRenderersFactory(assHandler, renderersFactory) + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory + .withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ).forDolbyVision(), + ).setSubtitleParserFactory(assSubtitleParserFactory) + } else { + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.forDolbyVision(), + ) + } + val disableAudioOffload = + appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false + val tunneling = + appPreferences.experimentalPreferences.get { videoTunnelingEnabled } + val trackSelector = + createTrackSelector( + tunneling = tunneling, + disableAudioOffload = disableAudioOffload, + ) + ExoPlayer .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .setTrackSelector(trackSelector) + .build() + .apply { + assHandler?.init(this) + withContext(WholphinDispatchers.Main) { + setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build(), + true, + ) + } + } + } + + PlayerBackend.EXTERNAL_PLAYER -> { + throw IllegalArgumentException("Cannot create a player for external playback") } } currentPlayer = newPlayer - return newPlayer + return PlayerCreation(newPlayer, assHandler) } + + fun createAudioPlayer( + disableAudioOffload: Boolean, + extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK, + ): ExoPlayer { + val rendererMode = + when (extensions) { + MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + val extractorsFactory = createExtractorsFactory() + val renderersFactory: RenderersFactory = + WholphinRenderersFactory(context, false) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + DefaultMediaSourceFactory( + OkHttpDataSource.Factory(authOkHttpClient), + extractorsFactory, + ) + val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) + return ExoPlayer + .Builder(context) + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .setTrackSelector(trackSelector) + .build() + .also { + it.setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true, + ) + } + } + + private fun createExtractorsFactory() = + DefaultExtractorsFactory() + .setConstantBitrateSeekingEnabled(true) + .setConstantBitrateSeekingAlwaysEnabled(true) + + 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) { @@ -157,11 +303,19 @@ val Player.isReleased: Boolean } } +data class PlayerCreation( + val player: Player, + val assHandler: AssHandler? = null, +) + // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 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( context: Context, extensionRendererMode: Int, @@ -172,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 4231d9b9..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 @@ -7,27 +7,28 @@ import com.github.damontecres.wholphin.data.model.BaseItem 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.ui.DefaultItemFields +import com.github.damontecres.wholphin.data.model.PlaylistItem +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.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.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 @@ -38,6 +39,11 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Create [Playlist]s (not Jellyfin server playlist) for playback + * + * Used to create a queue of episodes or from Play All button + */ @Singleton class PlaylistCreator @Inject @@ -49,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, @@ -59,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, @@ -71,10 +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)) } - suspend fun createFromPlaylistId( + /** + * Create from a server playlist ID + */ + private suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, sortAndDirection: SortAndDirection, @@ -83,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( @@ -114,15 +124,21 @@ class PlaylistCreator req = GetItemsRequest( parentId = item.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.LOGO, + ), includeItemTypes = includeItemTypes, recursive = true, 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 = @@ -130,9 +146,14 @@ class PlaylistCreator .execute(api, request) .content.items .convertAndAddParts() - return Playlist(items, 0) + return Playlist(items) } + /** + * Create a [Playlist] contextually based on the given item. + * + * For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed + */ suspend fun createFrom( item: BaseItemDto, startIndex: Int = 0, @@ -180,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 -> { @@ -226,21 +291,22 @@ class PlaylistCreator BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO, + BaseItemKind.TRAILER, -> { val list = buildList { - add(BaseItem(item, false)) + add(PlaylistItem.Media(BaseItem(item, false))) if (item.partCount.gt(1)) { api.videosApi .getAdditionalPart(item.id) .content.items .map { - BaseItem(it, false) + PlaylistItem.Media(BaseItem(it, false)) }.let(::addAll) } } - PlaylistCreationResult.Success(Playlist(list, 0)) + PlaylistCreationResult.Success(Playlist(list)) } // Not support yet @@ -256,48 +322,76 @@ class PlaylistCreator } } - private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> = + private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> = buildList { - this@convertAndAddParts.forEach { ep -> - add(BaseItem(ep, useSeriesForPrimary)) - if (ep.partCount.gt(1)) { - val parts = - api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> - 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 bc0c8f94..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 @@ -22,27 +22,15 @@ import javax.inject.Singleton import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds +/** + * Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media + */ @Singleton class RefreshRateService @Inject constructor( @param:ApplicationContext private val context: Context, ) { - private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - - val supportedDisplayModes get() = display.supportedModes.orEmpty() - - private val displayModes: List<DisplayMode> by lazy { - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - } - /** * Find the best display mode for the given stream and signal to change to it */ @@ -50,11 +38,23 @@ 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 } + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val displayModes = + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width @@ -122,6 +122,9 @@ class RefreshRateService MainActivity.instance.changeDisplayMode(0) } + /** + * Listens for the display to change so we known the refresh rate or resolution is updated + */ private class Listener( val displayId: Int, ) : DisplayManager.DisplayListener { @@ -161,36 +164,64 @@ class RefreshRateService if (refreshRateSwitch) { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - .filter { - it.refreshRateRounded % streamRate == 0 || // Exact multiple - it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps - } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } } else { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } } // Timber.v("display modes candidates: %s", candidates.joinToString("\n")) return if (!resolutionSwitch) { - candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } else { + // Exact resolution & frame rate candidates.firstOrNull { it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate } - ?: candidates.firstOrNull { - it.physicalWidth == streamWidth && + // Next highest resolution, exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate } + // Exact resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Next highest resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && + it.physicalHeight >= streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Fall back to largest resolution, exact frame rate ?: candidates .filter { it.refreshRateRounded == streamRate } .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Finally, just the highest resolution + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } } + + fun frameRateMatches( + refreshRateRounded: Int, + streamRate: Int, + ): Boolean { + return refreshRateRounded % streamRate == 0 || // Exact multiple + refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } } } +/** + * Wrapper for a [Display.Mode] + */ data class DisplayMode( val modeId: Int, val physicalWidth: Int, 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 new file mode 100644 index 00000000..c3fcb478 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -0,0 +1,315 @@ +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 +import com.github.damontecres.wholphin.ui.components.ScreensaverItem +import com.github.damontecres.wholphin.ui.formatDate +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +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.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * Handles the queue of items to show on the screensaver, both in-app or OS + */ +@Singleton +class ScreensaverService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val scope: CoroutineScope, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) + val state: StateFlow<ScreensaverState> = _state + + val keepScreenOn = MutableStateFlow(false) + + private var waitJob: Job? = null + private var dimJob: Job? = null + + init { + userPreferencesService.flow + .onEach { prefs -> + _state.update { + val enabled = + prefs.appPreferences.interfacePreferences.screensaverPreference.enabled + keepScreenOnInternal(enabled) + ScreensaverState( + enabled = enabled, + enabledTemp = false, + active = false, + paused = false, + dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled, + dimActive = false, + ) + } + }.launchIn(scope) + } + + /** + * Reset the timer before showing the in-app screensaver + */ + fun pulse() { + waitJob?.cancel() + if (_state.value.enabled) { +// Timber.v("pulse") + _state.update { + if (!it.active) { + it.copy( + active = false, + dimActive = false, + ) + } else { + it + } + } + + if (!_state.value.paused) { + waitJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + _state.update { + 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) + } + } + } + } + } + + /** + * Immediately start the in-app screensaver + */ + fun start() { + _state.update { + it.copy( + enabledTemp = true, + active = true, + dimActive = true, + ) + } + } + + /** + * Immediately stop the in-app screensaver + */ + fun stop(cancelJob: Boolean) { + _state.update { + it.copy( + enabledTemp = false, + active = false, + dimActive = false, + ) + } + if (cancelJob) { + waitJob?.cancel() + dimJob?.cancel() + } + } + + /** + * Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active + */ + fun keepScreenOn(keep: Boolean) { + scope.launchDefault { + val screensaverEnabled = state.value.enabled + val dimEnabled = state.value.dimEnabled + Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, 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, + dimActive = false, + paused = keep, + ) + } + if (!keep) { + pulse() + } + } + if (!screensaverEnabled) { + // If in-app screensaver is not enabled, send keep screen on to the OS + keepScreenOnInternal(keep) + } + } + } + + private fun keepScreenOnInternal(keep: Boolean) { + keepScreenOn.update { keep } + } + + /** + * Create a flow of items to show on the screensaver + */ + fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> = + flow { + val pager = + try { + createPager() + } catch (ex: Exception) { + Timber.e(ex, "Error creating pager for screensaver") + emit(ScreensaverItem.Error(ex)) + return@flow + } + Timber.v("Got %s items", pager.size) + var index = 0 + if (pager.isEmpty()) { + emit(ScreensaverItem.Empty) + } else { + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds + while (true) { + try { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { + context.imageLoader + .enqueue( + ImageRequest + .Builder(context) + .data(backdropUrl) + .build(), + ).job + .await() + emit( + ScreensaverItem.CurrentItem( + item, + backdropUrl, + logoUrl, + title ?: "", + ), + ) + delay(duration) + } + } + } catch (_: CancellationException) { + break + } catch (ex: Exception) { + Timber.e(ex, "Error fetching next item") + delay(duration) + } + index++ + if (index > pager.lastIndex) index = 0 + } + } + }.flowOn(WholphinDispatchers.Default).cancellable() + + private suspend fun createPager(): ApiRequestPager<GetItemsRequest> { + val prefs = + userPreferencesService.flow + .first() + .appPreferences + .interfacePreferences.screensaverPreference + val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } + val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } + val request = + GetItemsRequest( + recursive = true, + includeItemTypes = itemTypes, + imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), + sortBy = listOf(ItemSortBy.RANDOM), + maxOfficialRating = maxAge?.toString(), + hasParentalRating = maxAge?.let { true }, + ) + 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( + val enabled: Boolean, + 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/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt index c68cd3d2..685b858c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import okhttp3.OkHttpClient /** @@ -18,12 +20,12 @@ class SeerrApi( ) private set - val active: Boolean get() = api.baseUrl.isNotNullOrBlank() + val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED fun update( baseUrl: String, apiKey: String?, ) { - api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient) } } 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 f06a7529..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,9 +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.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest @@ -13,20 +9,23 @@ import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository 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.map import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -44,18 +43,34 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _current = MutableStateFlow<CurrentSeerr?>(null) - val current: StateFlow<CurrentSeerr?> = _current - val currentServer: Flow<SeerrServer?> = current.map { it?.server } - val currentUser: Flow<SeerrUser?> = current.map { it?.user } + private val _connection = MutableStateFlow<SeerrState>(SeerrState()) + val connection: StateFlow<SeerrState> = _connection + + val current: Flow<CurrentSeerr?> = + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } + val currentServer: Flow<SeerrServer?> = + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } + val currentUser: Flow<SeerrUser?> = + 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> = current.map { it != null && seerrApi.active } + val active: Flow<Boolean> = + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _current.update { null } + _connection.update { SeerrState() } + seerrApi.update("", null) + } + + fun error( + server: SeerrServer, + user: SeerrUser, + exception: Exception, + ) { + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -65,8 +80,11 @@ class SeerrServerRepository userConfig: SeerrUserConfig, ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() - _current.update { - CurrentSeerr(server, user, userConfig, publicSettings) + _connection.update { + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), + ) } } @@ -80,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( @@ -112,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( @@ -141,22 +159,103 @@ class SeerrServerRepository val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } val api = SeerrApiClient( - url, + createSeerrApiUrl(url), apiKey, okHttpClient .newBuilder() - .connectTimeout(5.seconds) + .connectTimeout(2.seconds) .readTimeout(6.seconds) .build(), ) - login(api, authMethod, username, passwordOrApiKey) + seerrLogin(api, authMethod, username, passwordOrApiKey) return LoadingState.Success } - suspend fun removeServer() { - val current = _current.value ?: return - seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + suspend fun removeServerForCurrentUser(): Boolean { + val user = + when (val conn = connection.value.connection) { + SeerrConnectionStatus.NotConfigured -> return false + is SeerrConnectionStatus.Error -> conn.user + is SeerrConnectionStatus.Success -> conn.current.user + } + val rows = seerrServerDao.deleteUser(user) 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 } } @@ -165,24 +264,47 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + +sealed interface SeerrConnectionStatus { + data object NotConfigured : SeerrConnectionStatus + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrConnectionStatus + + data class Success( + val current: CurrentSeerr, + ) : SeerrConnectionStatus +} + data class CurrentSeerr( val server: SeerrServer, val user: SeerrUser, 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?, @@ -214,62 +336,23 @@ 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, - ) { - init { - context as AppCompatActivity - context.lifecycleScope.launchIO { - serverRepository.currentUser.asFlow().collect { user -> - Timber.d("New user") - seerrServerRepository.clear() - if (user != null) { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .firstOrNull() - ?.let { seerrUser -> - val server = seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - try { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let - } - } else { - try { - seerrApi.api.usersApi.authMeGet() - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let - } - } - seerrServerRepository.set(server, seerrUser, userConfig) - } - } - } - } - } +fun CurrentSeerr?.imageUrlBuilder( + imageType: ImageType, + path: String?, +): String? { + if (this == null) return null + val cacheImages = serverConfig.cacheImages == true + val base = + if (cacheImages) { + server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" } - } + 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 "${base}${prefix}$path" +} 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 e38ba41b..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 @@ -1,11 +1,34 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.CreditCast +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 +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 @@ -22,6 +45,7 @@ class SeerrService constructor( private val seerApi: SeerrApi, private val seerrServerRepository: SeerrServerRepository, + private val imageUrlService: ImageUrlService, ) { val api: SeerrApiClient get() = seerApi.api @@ -40,35 +64,35 @@ class SeerrService api.searchApi .discoverTvGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun trending(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTrendingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTvUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() /** @@ -89,32 +113,18 @@ class SeerrService api.moviesApi .movieMovieIdSimilarGet(movieId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { api.tvApi .tvTvIdSimilarGet(tvId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.PERSON -> { - api.personApi - .personPersonIdCombinedCreditsGet(personId = it) - .let { credits -> - val cast = - credits.cast - ?.take(25) - ?.map(::DiscoverItem) - .orEmpty() - val crew = - credits.crew - ?.take(25) - ?.map(::DiscoverItem) - .orEmpty() - cast + crew - } + personCredits(it) } else -> { @@ -125,4 +135,372 @@ class SeerrService } else { 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 + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { tvId -> + api.tvApi.tvTvIdGet(tvId = tvId) + } + } else { + null + } + + suspend fun createImageUrl( + imageType: ImageType, + path: String?, + mediaInfo: MediaInfo?, + backdropHeight: Int = 1080, + backdropWidth: Int = 1920, + ): String? { + if (mediaInfo != null) { + val itemId = mediaInfo.jellyfinId + if (itemId != null) { + return imageUrlService.getItemImageUrl( + itemId = itemId, + imageType = imageType, + ) + } + } + 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 { + 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, + ) + } + } + } + } + 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 + } + } + 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 = + when (item) { + is MovieResult -> createDiscoverItem(item) + is MovieDetails -> createDiscoverItem(item) + is TvResult -> createDiscoverItem(item) + is TvDetails -> createDiscoverItem(item) + is SeerrSearchResult -> createDiscoverItem(item) + is CreditCast -> createDiscoverItem(item) + is CreditCrew -> createDiscoverItem(item) + else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}") + } + + suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = + DiscoverItem( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = + DiscoverItem( + id = movie.id ?: -1, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = + DiscoverItem( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = + SeerrAvailability.from(tv.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = + DiscoverItem( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = + SeerrAvailability.from(tv.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = + DiscoverItem( + id = search.id, + type = SeerrItemType.fromString(search.mediaType), + title = search.title ?: search.name, + subtitle = null, + overview = search.overview, + availability = + SeerrAvailability.from(search.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), + posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), + logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = + DiscoverItem( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.character, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), + jellyfinItemId = credit.mediaInfo?.jellyfinId, + ) + + suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = + DiscoverItem( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.job, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + 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 f71c902a..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,22 +9,32 @@ 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 +/** + * Listens for basic messages from the server such as messages + */ @ActivityScoped class ServerEventListener @Inject @@ -39,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) { @@ -63,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 55% 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 14a93ba2..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 @@ -21,8 +24,11 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * 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, @@ -39,42 +45,68 @@ class MediaReportService encodeDefaults = false } - fun sendReportFor(itemId: UUID) { + /** + * Fetch the media info and send it to the server + */ + fun sendMediaReportFor(itemId: UUID) { ioScope.launchIO(ExceptionHandler(autoToast = true)) { val item = api.userLibraryApi.getItem(itemId = itemId).content - sendReportFor(item) + sendMediaReportFor(item) } } - suspend fun sendReportFor(item: BaseItemDto) { + /** + * Send the media report for the given item + */ + 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 bd95ea1d..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 @@ -16,8 +16,10 @@ import javax.inject.Singleton @Singleton class SetupNavigationManager @Inject - constructor() { - var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) + constructor( + private val navigationManager: NavigationManager, + ) { + var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] 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 6de45f50..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 @@ -20,55 +34,77 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +/** + * Manage the track choices for media + */ @Singleton 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 @@ -94,73 +130,93 @@ class StreamChoiceService result } + /** + * Returns the audio stream that should play + */ 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) } } + /** + * Returns the audio stream that should play + */ 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. @@ -169,7 +225,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - seriesId: UUID?, + item: BaseItemDto, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -182,64 +238,103 @@ 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 } + /** + * Returns the subtitle stream that should play + */ fun chooseSubtitleStream( 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( @@ -252,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. */ @@ -340,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 db6cef2c..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,11 +1,17 @@ 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 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -45,38 +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 cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty() - if (cachedIds.isNotEmpty()) { - flow { - try { - emit( - SuggestionsResource.Success( - fetchItemsByIds(cachedIds, itemKind), - ), - ) - } catch (e: Exception) { - Timber.e(e, "Failed to fetch items") - emit(SuggestionsResource.Empty) - } - } - } else { + 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 { + Timber.v( + "Using cached suggestions for parent=%s kind=%s count=%d", + parentId, + itemKind, + cachedSuggestions.ids.size, + ) + flow { + 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 5542ea51..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( @@ -60,15 +60,20 @@ class SuggestionsCache ): CachedSuggestions? { val key = cacheKey(userId, libraryId, itemKind) return memoryCache.getOrPut(key) { - try { - mutex.withLock { - File(cacheDir, "$key.json").inputStream().use { + mutex.withLock { + try { + val cacheFile = File(cacheDir, "$key.json") + if (!cacheFile.exists()) { + return@withLock null + } + + cacheFile.inputStream().use { json.decodeFromStream<CachedSuggestions>(it) } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null } - } catch (ex: Exception) { - Timber.e(ex, "Exception reading from disk cache") - null } } } 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 edca582b..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 @@ -11,15 +11,17 @@ import androidx.work.PeriodicWorkRequestBuilder 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 import java.util.UUID import javax.inject.Inject +import kotlin.random.Random import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -31,8 +33,8 @@ class SuggestionsSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, - private val cache: SuggestionsCache, private val workManager: WorkManager, + @param:IoDispatcher private val dispatcher: CoroutineDispatcher, ) { private val activity = (context as? AppCompatActivity) @@ -40,17 +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) + } } } } @@ -60,6 +63,28 @@ class SuggestionsSchedulerService userId: UUID, serverId: UUID, ) { + val workInfos = + withContext(dispatcher) { + workManager + .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) + .get() + } + val activeWork = + workInfos.firstOrNull { + !it.state.isFinished + } + val scheduledUserId = + activeWork + ?.tags + ?.firstOrNull { it.startsWith("user:") } + ?.removePrefix("user:") + + val isAlreadyScheduledForUser = scheduledUserId == userId.toString() + if (isAlreadyScheduledForUser) { + Timber.d("SuggestionsWorker already scheduled for user %s", userId) + return + } + val constraints = Constraints .Builder() @@ -75,12 +100,18 @@ class SuggestionsSchedulerService val periodicWorkRequestBuilder = PeriodicWorkRequestBuilder<SuggestionsWorker>( repeatInterval = 12.hours.toJavaDuration(), + flexTimeInterval = 1.hours.toJavaDuration(), ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - .setInitialDelay(60.seconds.toJavaDuration()) + .addTag("user:$userId") + + val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) + periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) + + Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, 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 1ee5870b..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,48 +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 = - when (view.collectionType) { - CollectionType.MOVIES -> BaseItemKind.MOVIE - CollectionType.TVSHOWS -> BaseItemKind.SERIES - else -> 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() - cache.put( - userId, + Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) + fetchAndCacheSuggestions( + context, view.id, - itemKind, - suggestions.map { it.id }, + 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, ) } } @@ -127,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") @@ -135,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, @@ -214,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() @@ -245,7 +321,7 @@ class SuggestionsWorker GetItemsRequest( parentId = parentId, userId = userId, - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, + fields = extraFields, includeItemTypes = listOf(itemKind), genreIds = genreIds, recursive = true, @@ -255,7 +331,7 @@ class SuggestionsWorker sortOrder = sortOrder?.let { listOf(it) }, limit = limit, enableTotalRecordCount = false, - imageTypeLimit = 1, + imageTypeLimit = 0, ) return GetItemsRequestHandler .execute(api, request) @@ -267,5 +343,31 @@ 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) { + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + 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 3f676cba..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 @@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import javax.inject.Inject import javax.inject.Singleton +/** + * Gets trailers for media + */ @Singleton class TrailerService @Inject @@ -31,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 f4aa80dc..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 @@ -14,13 +15,15 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.UpdateChecker.Companion.ASSET_NAME 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 @@ -43,6 +46,9 @@ import javax.inject.Singleton import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +/** + * Checks if an app update is available + */ @Singleton class UpdateChecker @Inject @@ -51,19 +57,68 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - // TODO apk names - private const val ASSET_NAME = "Wholphin" - private const val APK_NAME = "$ASSET_NAME.apk" + 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 = true + 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) + } } + /** + * If the app hasn't recently checked, check for any updates and if there is one, show a toast message + * + * This is safe to call many times because it will only show the toast at most once every 12 hours + */ suspend fun maybeShowUpdateToast( updateUrl: String, showNegativeToast: Boolean = false, @@ -111,94 +166,74 @@ class UpdateChecker } } + /** + * Get the currently installed version + */ fun getInstalledVersion(): Version { val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - return Version.Companion.fromString(pkgInfo.versionName!!) + return Version.fromString(pkgInfo.versionName!!) } - suspend fun getLatestRelease(updateUrl: String): Release? { - return withContext(Dispatchers.IO) { - val preferRelease = - PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean("updatePreferRelease", true) + /** + * Get the release whose name parses to the given version. + * + * Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`, + * so this searches the release list instead of fetching a tag. + */ + suspend fun getRelease(version: Version): Release? { + val request = + Request + .Builder() + .url(RELEASES_URL) + .get() + .build() + 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 + } + } + } + } + /** + * Get the latest released version + */ + suspend fun getLatestRelease(updateUrl: String): Release? = + withContext(WholphinDispatchers.IO) { val request = Request .Builder() .url(updateUrl) .get() .build() - 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, preferRelease) } - 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 { - listOf() - } - return@use Release(version, downloadUrl, publishedAt, body, notes) - } else { - Timber.w("Update version parsing failed. name=$name") - } - } else { - Timber.w("Update check failed: ${it.message}") - } - return@use null + getRelease(request) + } + + private fun getRelease(request: Request): Release? { + return okHttpClient.newCall(request).execute().use { + if (it.isSuccessful) { + parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG) + } else { + Timber.w("Update check failed ${it.code}: ${it.message}") + null } } } - private fun getDownloadUrl( - assets: JsonArray, - preferRelease: Boolean, - ): String? { - val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" } - val releaseSuffix = if (preferRelease) "-release" else "-debug" - val preferredNames = - listOf( - "$ASSET_NAME${releaseSuffix}$abiSuffix.apk", - "$ASSET_NAME$releaseSuffix.apk", - "$ASSET_NAME.apk", - ) - var preferredAsset: JsonObject? = null - outer@ for (name in preferredNames) { - for (asset in assets) { - val assetName = - asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull - if (name == assetName) { - preferredAsset = asset.jsonObject - break@outer - } - } - } - return preferredAsset - ?.get("browser_download_url") - ?.jsonPrimitive - ?.contentOrNull - } - + /** + * Download and install an update + */ + @SuppressLint("RequestInstallPackagesPolicy") suspend fun installRelease( release: Release, callback: DownloadCallback, ) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { cleanup() val request = Request @@ -207,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 { @@ -229,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) } @@ -289,13 +322,16 @@ 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) } } return targetFile } + /** + * Check if the app has permission to write the download + */ fun hasPermissions(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || ( @@ -336,7 +372,7 @@ class UpdateChecker context.contentResolver.delete( MediaStore.Downloads.EXTERNAL_CONTENT_URI, "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?", - arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE), + arrayOf("$ASSET_NAME%", APK_MIME_TYPE), ) Timber.i("Deleted $deletedRows rows") } else { @@ -360,7 +396,19 @@ data class Release( val publishedAt: String?, val body: String?, val notes: List<String>, -) +) { + val content = + "# ${version}\n" + + ( + (notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") + + (body ?: "") + ).replace( + Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), + "#$1", + ) + // Remove the last line for full changelog since it's just a link + .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") +} interface DownloadCallback { fun contentLength(contentLength: Long) @@ -371,19 +419,53 @@ 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(), +): String? { + 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) { + if (abiSuffix.isNotNullOrBlank()) { + add("$ASSET_NAME$abiSuffix.apk") + } + add("$ASSET_NAME.apk") + } + } + var preferredAsset: JsonObject? = null + outer@ for (name in preferredNames) { + for (asset in assets) { + val assetName = + asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull + if (name == assetName) { + preferredAsset = asset.jsonObject + break@outer + } + } + } + return preferredAsset + ?.get("browser_download_url") + ?.jsonPrimitive + ?.contentOrNull } 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 90d68e52..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,10 +4,15 @@ 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.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton +/** + * Get the current user's [UserPreferences] + */ @Singleton class UserPreferencesService @Inject @@ -15,11 +20,10 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { - 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 f207562c..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,26 +1,23 @@ 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.ExceptionHandler -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn 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 @@ -32,18 +29,52 @@ import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Qualifier import javax.inject.Singleton +/** + * An [OkHttpClient] that includes the user's access token when making requests + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthOkHttpClient +/** + * A basic [OkHttpClient] that does not include auth + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class StandardOkHttpClient +/** + * A [CoroutineScope] with [WholphinDispatchers.IO] + */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +/** + * A [CoroutineScope] with [WholphinDispatchers.Default] + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultCoroutineScope + +/** + * [WholphinDispatchers.IO] + * + * @see IoCoroutineScope + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** + * [WholphinDispatchers.Default] + * + * @see DefaultCoroutineScope + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -57,12 +88,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -86,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( @@ -107,12 +132,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = OkHttpFactory(okHttpClient) + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) @Provides @Singleton fun jellyfin( - okHttpFactory: OkHttpFactory, + okHttpFactory: CoroutineContextApiClientFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, @@ -130,52 +155,29 @@ 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 { - preferences.appPreferences.updateInterfacePreferences { - putRememberedTabs(key(itemId), tabIndex) - } - } - } - } - } - } + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @Provides @Singleton @@ -188,4 +190,20 @@ object AppModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) + + @Provides + @Singleton + fun mostRecentServerProvider( + @ApplicationContext context: Context, + ): MostRecentServerProvider = MostRecentServerSharedPreferences(context) +} + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) } 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 31c92774..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 @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao @@ -13,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 @@ -40,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 @@ -71,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( @@ -85,4 +98,13 @@ object DatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore<Preferences> = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } 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 297cbd64..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 @@ -24,6 +25,9 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +/** + * Schedules the [TvProviderWorker] to update the OS resume watching row + */ @ActivityScoped class TvProviderSchedulerService @Inject @@ -40,10 +44,11 @@ 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) { + // Schedule a new worker whenever the user changes activity.lifecycleScope.launchIO(ExceptionHandler()) { Timber.i("Scheduling TvProviderWorker for ${user.user}") workManager @@ -70,6 +75,9 @@ class TvProviderSchedulerService } } + /** + * Run the [TvProviderWorker] as a one-off instead of scheduled + */ fun launchOneTimeRefresh() { if (supportsTvProvider) { activity.lifecycleScope.launchIO(ExceptionHandler()) { 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 d8816e71..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 @@ -43,6 +45,11 @@ import java.util.Date import java.util.UUID import kotlin.time.Duration.Companion.minutes +/** + * Updates the Android OS resume watching row for a given Jellyfin user + * + * @see TvProviderSchedulerService + */ @HiltWorker class TvProviderWorker @AssistedInject @@ -64,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() } @@ -80,7 +83,6 @@ class TvProviderWorker getPotentialItems( userId, prefs.homePagePreferences.enableRewatchingNextUp, - prefs.homePagePreferences.combineContinueNext, prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -89,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 = @@ -145,7 +147,6 @@ class TvProviderWorker private suspend fun getPotentialItems( userId: UUID, enableRewatching: Boolean, - combineContinueNext: Boolean, maxDaysNextUp: Int, ): List<BaseItem> { val resumeItems = latestNextUpService.getResume(userId, 10, true) @@ -154,11 +155,7 @@ class TvProviderWorker latestNextUpService .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } - return if (combineContinueNext) { - latestNextUpService.buildCombined(resumeItems, nextUpItems) - } else { - resumeItems + nextUpItems - } + return latestNextUpService.buildCombined(resumeItems, nextUpItems) } private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> = @@ -221,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, ) } @@ -265,6 +262,7 @@ class TvProviderWorker .getLatestMedia( GetLatestMediaRequest( fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), imageTypeLimit = 1, parentId = null, groupItems = true, @@ -363,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 899498fd..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 @@ -16,10 +17,32 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.Options import coil3.request.crossfade import coil3.util.DebugLogger +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences import okhttp3.OkHttpClient import timber.log.Timber import kotlin.time.ExperimentalTime +@Composable +fun CoilConfig( + prefs: AppPreferences, + okHttpClient: OkHttpClient, + debugLogging: Boolean, + enableCache: Boolean = true, +) = CoilConfig( + diskCacheSizeBytes = + prefs.advancedPreferences.imageDiskCacheSizeBytes.let { + if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) { + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } else { + it + } + }, + okHttpClient = okHttpClient, + debugLogging = debugLogging, + enableCache = enableCache, +) + /** * Configure Coil image loading */ @@ -73,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/CoilTrickplayTransformation.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt index ace495bf..dc91eb3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt @@ -18,7 +18,7 @@ class CoilTrickplayTransformation( val index: Int, ) : Transformation() { private val x: Int = imageIndex % numColumns - private val y: Int = imageIndex / numRows + private val y: Int = imageIndex / numColumns override val cacheKey: String get() = "CoilTrickplayTransformation_$index,$x,$y" 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 12854cd7..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 @@ -5,7 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -28,17 +27,19 @@ 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 +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA @@ -46,7 +47,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -74,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 */ @@ -242,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 } @@ -296,7 +298,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + * Tries to find the [Activity] for the given [Context] */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -310,18 +312,6 @@ fun Context.findActivity(): Activity? { return null } -/** - * Keep the screen on for an [Activity]. Often used with [findActivity]. - */ -fun Activity.keepScreenOn(keep: Boolean) { - Timber.v("Keep screen on: $keep") - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - /** * Selectively log errors from Coil image loading. * @@ -371,25 +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 [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 = WholphinDispatchers.Default + context, start = start, block = block) /** * Converts a UUID to the format used server-side (ie without hyphens). @@ -399,11 +398,6 @@ fun CoroutineScope.launchIO( */ 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?, @@ -418,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( @@ -433,11 +427,8 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } -@Composable -fun rememberBackDropImage(item: BaseItem): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } -} +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 @@ -445,3 +436,30 @@ fun rememberBackDropImage(item: BaseItem): String? { fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.lt(that: Int) = (this ?: 0) < that + +/** + * Simplifies endlessly collecting a flow + */ +fun <T> Flow<T>.collectLatestIn( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launchDefault { this@collectLatestIn.collectLatest(action) } +} + +/** + * 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 1842620a..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,22 +1,63 @@ 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 -val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) -val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") +/** + * 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 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 = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + +fun getDateFormatter(): DateTimeFormatter { + if (dateFormatter.locale != Locale.getDefault()) { + dateFormatter = dateFormatter.withLocale(Locale.getDefault()) + } + return dateFormatter +} // TODO server returns in UTC, but sdk converts to local time // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 @@ -24,9 +65,11 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy" /** * Format a [LocalDateTime] as `Aug 24, 2000` */ -fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) +fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) -fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime) + +fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) fun toLocalDate(date: String?): LocalDate? = date?.let { @@ -38,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() = @@ -76,7 +157,7 @@ val BaseItemDto.seriesProductionYears: String? append(productionYear.toString()) if (status == "Continuing") { append(" - ") - append("Present") + append(WholphinApplication.instance.getString(R.string.series_continueing)) } else if (status == "Ended") { endDate?.let { if (it.year != productionYear) { @@ -108,30 +189,32 @@ fun abbreviateNumber(number: Int): String { return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) } -val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") +val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") -/** - * Format bytes - */ -fun formatBytes( - bytes: Int, - suffixes: List<String> = byteSuffixes, -) = formatBytes(bytes.toLong(), suffixes) - fun formatBytes( bytes: Long, suffixes: List<String> = byteSuffixes, + divisor: Int = 1024, ): String { var unit = 0 var count = bytes.toDouble() - while (count >= 1024 && unit + 1 < suffixes.size) { - count /= 1024 + while (count >= divisor && unit + 1 < suffixes.size) { + count /= divisor unit++ } - return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) + return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit]) } +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() = @@ -158,28 +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_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_title + BaseItemKind.BOX_SET -> R.string.collections + 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 -> 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 -> 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_title + BaseItemKind.TV_CHANNEL -> R.string.channels + 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 958f9dfd..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,26 +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, ) /** @@ -65,25 +64,48 @@ 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 { - val height2x3 = 172.dp - val heightEpisode = height2x3 * .75f + 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 70de419b..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 @@ -2,19 +2,23 @@ package com.github.damontecres.wholphin.ui.cards 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.Row 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.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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource 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.dp @@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios 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.enableMarquee import org.jellyfin.sdk.model.api.ImageType /** @@ -60,11 +66,14 @@ fun BannerCard( cardHeight: Dp = 120.dp, aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = true, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current val fillHeight = - remember(cardHeight) { + remember(cardHeight, density) { if (cardHeight.isSpecified) { with(density) { cardHeight.roundToPx() @@ -74,23 +83,30 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight) { + remember(item, fillHeight, imageType, useSeriesForPrimary) { if (item != null) { - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = null, - fillHeight = fillHeight, - ) + item.imageUrlOverride + ?: imageUrlService.getItemImageUrl( + item, + imageType, + fillWidth = null, + fillHeight = fillHeight, + useSeriesForPrimary = useSeriesForPrimary, + ) } else { null } } - var imageError by remember { mutableStateOf(false) } + var imageError by remember(imageUrl) { mutableStateOf(false) } + + // Stabilize callbacks to prevent AsyncImage from recomposing + val currentOnClick by rememberUpdatedState(onClick) + val currentOnLongClick by rememberUpdatedState(onLongClick) + Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), - onClick = onClick, - onLongClick = onLongClick, + onClick = { currentOnClick() }, + onLongClick = { currentOnLongClick() }, interactionSource = interactionSource, colors = CardDefaults.colors( @@ -107,15 +123,15 @@ fun BannerCard( AsyncImage( model = imageUrl, contentDescription = null, - contentScale = ContentScale.FillBounds, - onError = { imageError = true }, + contentScale = imageContentScale, + onError = remember { { imageError = true } }, modifier = Modifier.fillMaxSize(), ) } else { Text( text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier @@ -181,3 +197,76 @@ fun BannerCard( } } } + +@Composable +fun BannerCardWithTitle( + title: String?, + subtitle: String?, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + cornerText: String? = null, + played: Boolean = false, + favorite: Boolean = false, + playPercent: Double = 0.0, + cardHeight: Dp = 120.dp, + aspectRatio: Float = AspectRatios.WIDE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, +) { + val focused by interactionSource.collectIsFocusedAsState() + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) + val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) + val width = cardHeight * aspectRationToUse + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.width(width), + ) { + BannerCard( + name = null, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier, + cornerText = cornerText, + played = played, + favorite = favorite, + playPercent = playPercent, + cardHeight = cardHeight, + aspectRatio = aspectRatio, + interactionSource = interactionSource, + imageType = imageType, + imageContentScale = imageContentScale, + useSeriesForPrimary = useSeriesForPrimary, + ) + SlidingCardText(focused) { + Text( + text = title ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = subtitle ?: "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} 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 4e1d0e86..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 @@ -16,7 +16,6 @@ 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.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.pluralStringResource 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.dp @@ -49,18 +49,16 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlinx.coroutines.delay @Composable -@NonRestartableComposable fun DiscoverItemCard( item: DiscoverItem?, onClick: () -> Unit, onLongClick: () -> Unit, - showOverlay: Boolean, modifier: Modifier = Modifier, + showOverlay: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + 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 @@ -76,16 +74,15 @@ fun DiscoverItemCard( } 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), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.size(width, Dp.Unspecified), ) { Card( modifier = Modifier - .size(Dp.Unspecified, Cards.height2x3) + .size(width, Dp.Unspecified) .aspectRatio(AspectRatios.TALL), onClick = onClick, onLongClick = onLongClick, @@ -131,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 = @@ -172,17 +178,13 @@ fun DiscoverItemCard( } } } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -193,6 +195,8 @@ fun DiscoverItemCard( text = item?.releaseDate?.year?.toString() ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() @@ -281,6 +285,31 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } } +@Composable +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), + ) { + Text( + text = stringResource(R.string.fa_xmark), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + @PreviewTvSpec @Composable private fun Preview() { @@ -289,6 +318,7 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() + 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 e4ffa5ee..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ /dev/null @@ -1,151 +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.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.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, - 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) - 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 = false, - 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, - 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, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.name ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - 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 adfff582..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 @@ -1,19 +1,13 @@ package com.github.damontecres.wholphin.ui.cards 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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem -import com.github.damontecres.wholphin.data.pluralRes import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import org.jellyfin.sdk.model.api.ImageType @Composable fun ExtrasRow( @@ -22,54 +16,29 @@ fun ExtrasRow( onLongClickItem: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val resources = LocalResources.current ItemRow( - title = stringResource(R.string.extras), + title = stringResource(R.string.extras_title), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, cardContent = { index, item, mod, onClick, onLongClick -> - val imageUrlService = LocalImageUrlService.current - val imageUrl = - remember { - val item = - when (item) { - is ExtrasItem.Group -> item.items.random() - is ExtrasItem.Single -> item.item - null -> null - } - imageUrlService.getItemImageUrl(item, ImageType.PRIMARY) - } SeasonCard( - title = - when (item) { - is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) } - is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) } - null -> null - }, + title = item?.title, name = null, - subtitle = - if (item is ExtrasItem.Single && item.title != null) { - item.type.pluralRes.let { resources.getQuantityString(it, 1) } - } else if (item is ExtrasItem.Group) { - resources.getQuantityString(R.plurals.items, item.items.size, item.items.size) - } else { - null - }, + subtitle = item?.subtitle, onClick = onClick, onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.height2x3 * .75f, + imageHeight = Cards.heightEpisode, imageWidth = Dp.Unspecified, - imageUrl = imageUrl, + imageUrl = item?.imageUrl, isFavorite = false, - isPlayed = false, + isPlayed = item?.isPlayed == true, unplayedItemCount = -1, - playedPercentage = -1.0, + 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 73cf5ba3..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 @@ -42,11 +41,29 @@ fun GenreCard( onLongClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = GenreCard( + genreId = genre?.id, + name = genre?.name, + imageUrl = genre?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun GenreCard( + genreId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val background = rememberIdColor(genre?.id).copy(alpha = .6f) + val background = rememberIdColor(genreId).copy(alpha = .6f) Card( - modifier = - modifier, + modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -63,12 +80,12 @@ fun GenreCard( .fillMaxSize() .clip(RoundedCornerShape(8.dp)), ) { - if (genre?.imageUrl.isNotNullOrBlank()) { + if (imageUrl != null) { AsyncImage( model = ImageRequest .Builder(LocalContext.current) - .data(genre.imageUrl) + .data(imageUrl) .crossfade(true) .build(), contentScale = ContentScale.FillBounds, @@ -88,7 +105,7 @@ fun GenreCard( .background(background), ) { Text( - text = genre?.name ?: "", + text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, @@ -112,7 +129,6 @@ private fun GenreCardPreview() { UUID.randomUUID(), "Adventure", null, - Color.Black, ) GenreCard( genre = genre, 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 7b653865..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 @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -46,11 +47,11 @@ fun GridCard( imageContentScale: ContentScale = ContentScale.Fit, imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, showTitle: Boolean = true, + fillWidth: Int? = null, + fillHeight: Int? = null, ) { 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 @@ -68,7 +69,7 @@ fun GridCard( } Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Card( @@ -94,6 +95,8 @@ fun GridCard( numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, + fillWidth = fillWidth, + fillHeight = fillHeight, modifier = Modifier .fillMaxWidth() @@ -102,17 +105,13 @@ fun GridCard( ) } AnimatedVisibility(showTitle) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, modifier = Modifier @@ -124,6 +123,8 @@ fun GridCard( text = item?.subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() 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 8964477a..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 @@ -24,11 +24,10 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -40,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 @@ -63,17 +63,18 @@ fun ItemCardImage( imageType: ImageType = ImageType.PRIMARY, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, + fillWidth: Int? = null, + fillHeight: Int? = null, ) { val imageUrlService = LocalImageUrlService.current - var size by remember { mutableStateOf(IntSize.Zero) } val imageUrl = - remember(size, item) { - if (size != IntSize.Zero && item != null) { + remember(item, imageType, fillWidth, fillHeight) { + if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) { imageUrlService.getItemImageUrl( item, imageType, - fillWidth = size.width, - fillHeight = size.height, + fillWidth = fillWidth, + fillHeight = fillHeight, ) } else { null @@ -88,13 +89,7 @@ fun ItemCardImage( unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, numberOfVersions = numberOfVersions, - modifier = - modifier.onLayoutRectChanged( - throttleMillis = 100, - debounceMillis = 25, - ) { - size = IntSize(width = it.width, height = it.height) - }, + modifier = modifier, useFallbackText = useFallbackText, contentScale = contentScale, ) @@ -236,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( @@ -276,7 +265,7 @@ fun ItemCardImageOverlay( } } - watchedPercent?.let { percent -> + if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { Box( modifier = Modifier @@ -285,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 7ebf0faf..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 @@ -5,12 +5,15 @@ 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.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -39,11 +42,17 @@ 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() } val focusRequester = remember { FocusRequester() } var position by rememberInt() + + val currentOnClickItem by rememberUpdatedState(onClickItem) + val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -53,12 +62,8 @@ fun <T> ItemRow( } }, ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, - ) + ItemRowTitle(title) + LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(horizontalPadding), @@ -72,25 +77,63 @@ fun <T> ItemRow( ) { itemsIndexed(items) { index, item -> val cardModifier = - if (index == position) { - Modifier.focusRequester(firstFocus) - } else { - Modifier + remember(index, position) { + if (index == position) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } } + + val onClick = + remember(index, item) { + { + position = index + if (item != null) currentOnClickItem(index, item) + } + } + + val onLongClick = + remember(index, item) { + { + position = index + if (item != null) currentOnLongClickItem(index, item) + } + } + cardContent.invoke( index, item, cardModifier, - { - position = index - if (item != null) onClickItem.invoke(index, item) - }, - { - position = index - if (item != null) onLongClickItem.invoke(index, item) - }, + onClick, + onLongClick, ) } + if (showViewMore) { + item { + val cardModifier = + remember(items.size, position) { + if (position == items.size) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + } + viewMoreCardContent.invoke(cardModifier) + } + } } } } + +@Composable +@NonRestartableComposable +fun ItemRowTitle( + title: String, + modifier: Modifier = Modifier, +) = Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = modifier.padding(start = 8.dp), +) 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 47fd4e94..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 @@ -26,6 +25,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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.sp @@ -79,7 +79,7 @@ fun PersonCard( ) { val hideOverlayDelay = 1_000L - val focused = interactionSource.collectIsFocusedAsState().value + val focused by interactionSource.collectIsFocusedAsState() var focusedAfterDelay by remember { mutableStateOf(false) } if (focused) { @@ -94,10 +94,9 @@ fun PersonCard( } else { focusedAfterDelay = false } - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier, ) { Card( @@ -163,17 +162,13 @@ fun PersonCard( .clip(CircleShape), ) } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { + SlidingCardText(focused) { Text( text = name ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -185,6 +180,8 @@ fun PersonCard( text = role, maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() 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 e3e6059e..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 @@ -16,15 +16,18 @@ 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.graphics.Color +import androidx.compose.ui.layout.layout 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.compose.ui.unit.isSpecified 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.AspectRatios @@ -34,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) { - 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, @@ -100,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 */ @@ -124,28 +138,20 @@ fun SeasonCard( aspectRatio: Float = 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) } + // Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates + val spaceBetween = + animateDpAsState( + if (focused) spaceBetweenFocused else spaceBetweenUnfocused, + label = "spaceBetween", + ) - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = imageHeight * aspectRationToUse val height = imageWidth * (1f / aspectRationToUse) + Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), + verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing modifier = modifier.size(width, height), ) { Card( @@ -186,13 +192,21 @@ fun SeasonCard( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), + .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(), ) { Text( text = title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -203,6 +217,8 @@ fun SeasonCard( text = subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() @@ -212,3 +228,22 @@ fun SeasonCard( } } } + +/** + * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while + */ +@Composable +fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> { + val focused by interactionSource.collectIsFocusedAsState() + val state = remember { mutableStateOf(false) } + + LaunchedEffect(focused) { + if (!focused) { + state.value = false + return@LaunchedEffect + } + delay(500L) + state.value = true + } + return state +} 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 new file mode 100644 index 00000000..c128285d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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 coil3.compose.AsyncImage +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.Studio +import com.github.damontecres.wholphin.ui.setup.rememberIdColor +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import java.util.UUID + +@Composable +fun StudioCard( + studio: Studio?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = StudioCard( + studioId = studio?.id, + name = studio?.name, + imageUrl = studio?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun StudioCard( + studioId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val background = rememberIdColor(studioId).copy(alpha = .4f) + var error by remember { mutableStateOf(false) } + Card( + modifier = modifier, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + ) { + if (imageUrl != null && !error) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentScale = ContentScale.FillBounds, + contentDescription = null, + onError = { + error = true + }, + modifier = + Modifier + .alpha(.75f) + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize(), + ) + } else { + Box( + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .background(background), + ) { + Text( + text = name ?: "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(16.dp) + .align(Alignment.Center), + ) + } + } + } + } +} + +@PreviewTvSpec +@Composable +private fun GenreCardPreview() { + WholphinTheme { + val studio = + Studio( + UUID.randomUUID(), + "Adventure", + null, + ) + StudioCard( + studio = studio, + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(180.dp), + ) + } +} 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/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt new file mode 100644 index 00000000..8aa43946 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -0,0 +1,302 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.layout.size +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.graphicsLayer +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 +import androidx.datastore.core.DataStore +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 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.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ScreensaverViewModel + @Inject + constructor( + private val screensaverService: ScreensaverService, + val preferencesDataStore: DataStore<AppPreferences>, + ) : ViewModel() { + val currentItem = screensaverService.createItemFlow(viewModelScope) + } + +sealed interface ScreensaverItem { + data class Error( + val exception: Exception, + ) : ScreensaverItem + + data object Empty : ScreensaverItem + + data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, + ) : ScreensaverItem +} + +@Composable +fun AppScreensaver( + prefs: AppPreferences, + modifier: Modifier = Modifier, + viewModel: ScreensaverViewModel = hiltViewModel(), +) { + val currentItem by viewModel.currentItem.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = prefs.interfacePreferences.screensaverPreference.showClock, + duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, + animate = prefs.interfacePreferences.screensaverPreference.animate, + modifier = modifier, + ) +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun AppScreensaverContent( + currentItem: ScreensaverItem?, + showClock: Boolean, + duration: Duration, + animate: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier + .background(Color.Black), + ) { + val infiniteTransition = rememberInfiniteTransition() + val scale by infiniteTransition.animateFloat( + 1f, + if (animate) 1.1f else 1f, + infiniteRepeatable( + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + delayMillis = 500, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + when (currentItem) { + ScreensaverItem.Empty -> { + ScreensaverPlaceholder( + text = stringResource(R.string.no_results), + duration = duration, + modifier = Modifier.fillMaxSize(), + ) + } + + is ScreensaverItem.Error -> { + ScreensaverPlaceholder( + text = "Error connecting to Jellyfin server", + duration = duration, + modifier = Modifier.fillMaxSize(), + ) + } + + null, + is ScreensaverItem.CurrentItem, + -> { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.backdropUrl) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + + var logoError by remember(currentItem) { mutableStateOf(false) } + if (!logoError) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.logoUrl) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .build(), + contentDescription = "Logo", + onError = { + logoError = true + }, + modifier = + Modifier + .align(Alignment.BottomStart) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + } + } + } + + val largeRadialGradient = + remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val biggerDimension = maxOf(size.height, size.width) + return RadialGradientShader( + colors = listOf(Color.Transparent, AppColors.TransparentBlack25), + center = size.center, + radius = biggerDimension / 1.5f, + colorStops = listOf(0f, 0.85f), + ) + } + } + } + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = largeRadialGradient, + blendMode = BlendMode.Multiply, + ) + if (showClock) { + // Add scrim to make clock more readable + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + } + if (showClock) { + TimeDisplay() + } + } +} + +@Composable +fun ScreensaverPlaceholder( + text: String, + duration: Duration, + modifier: Modifier = Modifier, +) { + var alignment by remember { mutableStateOf(Alignment.BottomStart) } + val alignments = + remember(alignment) { + mutableListOf( + Alignment.TopStart, + Alignment.TopCenter, + Alignment.TopEnd, + Alignment.CenterStart, + Alignment.Center, + Alignment.CenterEnd, + Alignment.BottomStart, + Alignment.BottomCenter, + Alignment.BottomEnd, + ).apply { remove(alignment) } + } + LaunchedEffect(Unit) { + while (true) { + delay(duration) + alignment = alignments.random() + } + } + AnimatedContent( + targetState = alignment, + label = "alignment animation", + transitionSpec = { + fadeIn(animationSpec = tween(500, delayMillis = 100)) + .togetherWith(fadeOut(animationSpec = tween(500, 100))) + }, + modifier = modifier, + ) { align -> + Box(Modifier.fillMaxSize()) { + Text( + text = text, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.error, + modifier = + Modifier + .align(align) + .padding(40.dp), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt index c65069bd..ff85886d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt @@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Surface import androidx.tv.material3.Text +/** + * This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc + * + * This allows for creating smaller and fully circular Buttons. + */ @Composable fun Button( onClick: () -> Unit, 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 6d20211f..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,909 +1,117 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.animation.AnimatedVisibility -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.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +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.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.NonRestartableComposable 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.res.stringResource +import androidx.compose.ui.platform.LocalDensity 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.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.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.NavigationManager 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.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.ItemViewModel -import com.github.damontecres.wholphin.ui.detail.MoreDialogActions -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome -import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.detail.GridItemDetails +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.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils -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.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec 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, - private val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - val navigationManager: NavigationManager, - 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 { - 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) - } catch (ex: Exception) { - Timber.e(ex, "Error during init") - loading.setValueOnMain(DataLoadingState.Error(ex)) - } - } - } - - 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), - 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) - } - } - } - -/** - * Shows a collection folder as a grid - * - * This is the "Library" tab for Movies or TV shows - */ +@OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderGrid( +fun CollectionFolderViewScope.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, - 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: String, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, - 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 moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - - when (val state = loading) { - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage() - } - - is DataLoadingState.Error, - is DataLoadingState.Success<*>, - -> { - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Box(modifier = modifier) { - CollectionFolderGridContent( - preferences = preferences, - initialPosition = viewModel.position, - item = item, - title = title, - loadingState = state as DataLoadingState<List<BaseItem?>>, - sortAndDirection = sortAndDirection!!, - modifier = Modifier.fillMaxSize(), - focusRequesterOnEmpty = focusRequesterOnEmpty, - onClickItem = onClickItem, - onLongClickItem = { position, item -> - moreDialog.makePresent(PositionItem(position, item)) - }, - 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 = { 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.navigationManager.navigateTo(destination) - }, - 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.navigationManager.navigateTo(destination) - } - }, - ) - - 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), - ) - } - } - } - } - moreDialog.compose { (position, item) -> - DialogPopup( - showDialog = true, - title = item.title ?: "", - dialogItems = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - actions = - MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickWatch = { itemId, watched -> - viewModel.setWatched(position, itemId, watched) - }, - onClickFavorite = { itemId, watched -> - viewModel.setFavorite(position, itemId, watched) - }, - onClickAddPlaylist = { - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(it) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ), - ), - onDismissRequest = { moreDialog.makeAbsent() }, - dismissOnClick = true, - waitToLoad = true, - ) - } - 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, - ) - } -} - -@Composable -fun CollectionFolderGridContent( - 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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (showTitle) { - Text( - text = title, - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } - 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) { HomePageHeader( item = focusedItem, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .padding(16.dp), + .height(200.dp) + .padding(HeaderUtils.padding), ) } - when (val state = loadingState) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - // This shouldn't happen, so just show placeholder - Text("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, onClick, onLongClick, mod -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - modifier = mod, - ) - }, - columns = viewOptions.columns, - spacing = viewOptions.spacing.dp, - ) - 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 + } + }, + ) } } } @@ -916,17 +124,13 @@ data class PositionItem( data class CollectionFolderGridParameters( val columns: Int = 6, val spacing: Dp = 16.dp, - val cardContent: @Composable ( - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - mod: Modifier, - ) -> Unit = { item, onClick, onLongClick, mod -> + val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, + fillWidth = widthPx, modifier = mod, ) }, @@ -936,13 +140,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.TALL, + fillWidth = widthPx, modifier = mod, ) }, @@ -951,13 +156,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 4, spacing = 24.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.Crop, imageAspectRatio = AspectRatios.WIDE, + fillWidth = widthPx, modifier = mod, ) }, @@ -966,13 +172,14 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { item, onClick, onLongClick, mod -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.SQUARE, + fillWidth = widthPx, modifier = mod, ) }, @@ -988,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 -> { @@ -1003,6 +212,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind> ) } + CollectionType.MUSICVIDEOS -> { + listOf(BaseItemKind.MUSIC_VIDEO) + } + CollectionType.BOXSETS -> { listOf(BaseItemKind.BOX_SET) } @@ -1011,7 +224,175 @@ 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() } } + +@Composable +@NonRestartableComposable +fun GridTitle( + title: String, + modifier: Modifier = Modifier, +) = Text( + text = title, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.fillMaxWidth(), +) + +data class GridClickActions( + val onClickItem: (Int, BaseItem) -> Unit, + val onLongClickItem: ((Int, BaseItem) -> Unit)? = null, + 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 new file mode 100644 index 00000000..f87010d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -0,0 +1,970 @@ +package com.github.damontecres.wholphin.ui.components + +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 +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +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.graphics.Color +import androidx.compose.ui.platform.LocalResources +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 +import com.github.damontecres.wholphin.data.model.AudioItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.Person +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 +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration + +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, + val canDelete: Boolean, + val canRemoveContinueWatching: Boolean, + val canRemoveNextUp: Boolean, + val actions: ContextMenuActions, + val showRemoveFromPlaylist: Boolean = false, + ) : ContextMenu + + data class ForPerson( + val fromLongClick: Boolean, + val person: Person, + val actions: PersonContextActions, + ) : ContextMenu + + data class ForMusic( + val fromLongClick: Boolean, + val item: BaseItem, + val index: Int, + val canDelete: Boolean, + val canRemoveFromQueue: Boolean, + val actions: MusicContextActions, + val showRemoveFromPlaylist: Boolean = false, + val showGoTo: Boolean = false, + ) : ContextMenu + + data class ForQueue( + val fromLongClick: Boolean, + val item: AudioItem, + val index: Int, + val actions: QueueContextActions, + ) : ContextMenu + + companion object { + const val UNSPECIFIED_INDEX = -12 + } +} + +data class ContextMenuActions( + val navigateTo: (Destination) -> Unit, + val onShowOverview: (BaseItem) -> Unit, + val onClickWatch: (UUID, Boolean) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onSendMediaInfo: (UUID) -> Unit, + val onDeleteItem: (BaseItem) -> Unit, + val onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, + val onChooseTracks: (ChosenTrackResult) -> Unit, + val onClearChosenStreams: (ChosenStreams?) -> Unit, + val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, + val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, + val onClickAddToQueue: (BaseItem) -> Unit = {}, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, +) + +data class PersonContextActions( + val navigateTo: (Destination) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, +) + +data class MusicContextActions( + val navigateTo: (Destination) -> Unit, + val onClickPlay: (Int, BaseItem) -> Unit, + val onClickPlayNext: (Int, BaseItem) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onDeleteItem: (BaseItem) -> Unit, + val onClickAddToQueue: (BaseItem) -> Unit, + val onClickRemoveFromQueue: (Int, BaseItem) -> Unit, + val onClickGoToAlbum: (UUID) -> Unit = { + navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) + }, + val onClickGoToArtist: (UUID) -> Unit = { + navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) + }, + val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, +) + +data class QueueContextActions( + val onNavigate: (Destination) -> Unit, + val onClickPlay: (Int, AudioItem) -> Unit, + val onClickPlayNext: (Int, AudioItem) -> Unit, + val onClickGoToAlbum: (java.util.UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) + }, + val onClickGoToArtist: (java.util.UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) + }, + val onClickRemoveFromQueue: (Int, AudioItem) -> Unit, +) + +data class ChosenTrackResult( + val item: BaseItem, + val streamType: MediaStreamType, + val trackIndex: Int, + val itemPlayback: ItemPlayback?, +) + +@Composable +fun ContextMenuDialog( + onDismissRequest: () -> Unit, + contextMenu: ContextMenu, + getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?, + preferredSubtitleLanguage: String?, +) { + when (contextMenu) { + is ContextMenu.ForBaseItem -> { + ContextMenu( + onDismissRequest, + contextMenu, + getMediaSource, + preferredSubtitleLanguage, + contextMenu.actions, + ) + } + + is ContextMenu.ForPerson -> { + ContextMenu( + onDismissRequest = onDismissRequest, + item = contextMenu, + actions = contextMenu.actions, + ) + } + + is ContextMenu.ForMusic -> { + ContextMenu( + onDismissRequest = onDismissRequest, + item = contextMenu, + actions = contextMenu.actions, + ) + } + + is ContextMenu.ForQueue -> { + ContextMenu( + onDismissRequest = onDismissRequest, + item = contextMenu, + actions = contextMenu.actions, + ) + } + } +} + +@Composable +fun ContextMenu( + onDismissRequest: () -> Unit, + contextMenu: ContextMenu.ForBaseItem, + getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?, + preferredSubtitleLanguage: String?, + actions: ContextMenuActions, +) { + val item = contextMenu.item + val chosenStreams = contextMenu.chosenStreams + 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(resources, item, chosenStreams) { + buildContextMenuItems( + 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?.audioStc?.isNotEmpty() == true || + it?.subtitleStc?.isNotEmpty() == true + }, + contextMenu = contextMenu, + actions = actions, + onChooseVersion = { + chooseVersion = + chooseVersionParams( + resources, + item.data.mediaSources.orEmpty(), + chosenStreams?.source?.id?.toUUIDOrNull(), + ) { idx -> + val source = item.data.mediaSources!![idx] + actions.onChooseVersion.invoke(item, source) + onDismissRequest.invoke() + } + }, + onChooseTracks = { type -> + getMediaSource + ?.invoke( + item.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + resources = resources, + streams = source.mediaStreams.orEmpty(), + type = type, + streamChoice = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream + } else { + chosenStreams?.subtitleStream + }, + onClick = { trackIndex -> + actions.onChooseTracks.invoke( + ChosenTrackResult( + item = item, + streamType = type, + trackIndex = trackIndex, + itemPlayback = chosenStreams?.itemPlayback, + ), + ) + onDismissRequest.invoke() + }, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } + }, + onShowOverview = { actions.onShowOverview.invoke(item) }, + onClearChosenStreams = { + actions.onClearChosenStreams.invoke(chosenStreams) + }, + onClickDelete = { showDeleteDialog = true }, + onClickPlayWith = { showPlayWithDialog = true }, + ) + } + DialogPopup( + showDialog = true, + title = item.title ?: "", + dialogItems = dialogItems, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + waitToLoad = contextMenu.fromLongClick, + ) + if (chooseVersion != null) { + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + elevation = 3.dp, + ) + } + } + if (showDeleteDialog) { + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = false }, + onConfirm = { + actions.onDeleteItem.invoke(item) + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + if (showPlayWithDialog) { + val dialogItems = + remember { + buildPlayWith(resources) { transcode, backend -> + onDismissRequest.invoke() + actions.navigateTo( + Destination.Playback( + itemId = item.id, + positionMs = item.resumeMs, + forceTranscoding = transcode, + backend = backend, + ), + ) + } + } + DialogPopup( + showDialog = true, + title = stringResource(R.string.play_with), + dialogItems = dialogItems, + onDismissRequest = { showPlayWithDialog = false }, + dismissOnClick = true, + waitToLoad = false, + elevation = 3.dp, + ) + } +} + +private fun buildContextMenuItems( + resources: Resources, + contextMenu: ContextMenu.ForBaseItem, + item: BaseItem, + seriesId: UUID?, + sourceId: UUID?, + watched: Boolean, + favorite: Boolean, + canClearChosenStreams: Boolean, + actions: ContextMenuActions, + onChooseVersion: () -> Unit, + onChooseTracks: (MediaStreamType) -> Unit, + onShowOverview: () -> Unit, + onClearChosenStreams: () -> Unit, + onClickDelete: () -> Unit, + onClickPlayWith: () -> Unit, +): List<DialogItem> = + buildList { + // Songs should not show Go to + if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { + add( + DialogItem( + resources.getString(R.string.go_to), + Icons.Default.ArrowForward, + dismissOnClick = true, + ) { + actions.onClickGoTo(item) + }, + ) + } + if (item.type in supportedPlayableTypes) { + if (item.playbackPosition > Duration.ZERO) { + add( + DialogItem( + resources.getString(R.string.resume), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.Playback( + item.id, + item.playbackPosition.inWholeMilliseconds, + ), + ) + }, + ) + add( + DialogItem( + resources.getString(R.string.restart), + Icons.Default.Refresh, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.Playback( + item.id, + 0L, + ), + ) + }, + ) + } else { + add( + DialogItem( + resources.getString(R.string.play), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.Playback( + item.id, + 0L, + ), + ) + }, + ) + } + } + 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 } } + ?: sources.firstOrNull() + source?.mediaStreams?.letNotEmpty { streams -> + val audioCount = streams.count { it.type == MediaStreamType.AUDIO } + val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE } + if (audioCount > 1) { + add( + DialogItem( + resources.getString( + R.string.choose_stream, + resources.getString(R.string.audio), + ), + R.string.fa_volume_low, + dismissOnClick = false, + ) { + onChooseTracks.invoke(MediaStreamType.AUDIO) + }, + ) + } + if (subtitleCount > 0) { + add( + DialogItem( + resources.getString( + R.string.choose_stream, + resources.getString(R.string.subtitles), + ), + R.string.fa_closed_captioning, + dismissOnClick = false, + ) { + onChooseTracks.invoke(MediaStreamType.SUBTITLE) + }, + ) + } + } + if (sources.size > 1) { + add( + DialogItem( + resources.getString( + R.string.choose_stream, + resources.getString(R.string.version), + ), + R.string.fa_file_video, + dismissOnClick = false, + ) { + onChooseVersion.invoke() + }, + ) + } + } + } + if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) { + add( + DialogItem( + resources.getString(R.string.add_to_queue), + Icons.Default.Add, + dismissOnClick = true, + ) { + actions.onClickAddToQueue(item) + }, + ) + } + 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, + iconStringRes = R.string.fa_list_ul, + dismissOnClick = true, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + if (contextMenu.canDelete) { + add( + DialogItem( + resources.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + dismissOnClick = false, + ) { + onClickDelete.invoke() + }, + ) + } + if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { + add( + DialogItem( + text = R.string.remove_continue_watching, + iconStringRes = R.string.fa_eye, + dismissOnClick = true, + ) { + actions.onClickWatch.invoke(item.id, false) + }, + ) + } + if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + add( + DialogItem( + text = R.string.remove_next_up, + iconStringRes = R.string.fa_tag, + dismissOnClick = true, + ) { + 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, + dismissOnClick = true, + ) { + actions.onClickWatch.invoke(item.id, !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, + dismissOnClick = true, + ) { + actions.onClickFavorite.invoke(item.id, !favorite) + }, + ) + item.data.albumId?.let { albumId -> + add( + DialogItem( + resources.getString(R.string.go_to_album), + R.string.fa_compact_disc, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.MediaItem( + albumId, + BaseItemKind.MUSIC_ALBUM, + null, + ), + ) + }, + ) + } + item.data.artistItems?.firstOrNull()?.id?.let { artistId -> + add( + DialogItem( + resources.getString(R.string.go_to_artist), + R.string.fa_user, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.MediaItem( + artistId, + BaseItemKind.MUSIC_ARTIST, + null, + ), + ) + }, + ) + } + seriesId?.let { + add( + DialogItem( + resources.getString(R.string.go_to_series), + Icons.AutoMirrored.Filled.ArrowForward, + dismissOnClick = true, + ) { + actions.navigateTo( + Destination.MediaItem( + seriesId, + BaseItemKind.SERIES, + null, + ), + ) + }, + ) + } + if (item.data.mediaSources?.isNotEmpty() == true) { + add( + DialogItem( + resources.getString(R.string.media_information), + Icons.Default.Info, + dismissOnClick = true, + ) { + onShowOverview.invoke() + }, + ) + } + if (contextMenu.showStreamChoices && canClearChosenStreams) { + add( + DialogItem( + resources.getString(R.string.clear_track_choices), + Icons.Default.Delete, + dismissOnClick = true, + ) { + onClearChosenStreams() + }, + ) + } + if (item.type in supportedPlayableTypes) { + add( + DialogItem( + resources.getString(R.string.play_with), + Icons.Default.PlayArrow, + dismissOnClick = false, + onClick = onClickPlayWith, + ), + ) + } + if (item.data.mediaSources?.isNotEmpty() == true) { + add( + DialogItem( + text = R.string.send_media_info_log_to_server, + iconStringRes = R.string.fa_file_video, + dismissOnClick = true, + ) { + actions.onSendMediaInfo.invoke(item.id) + }, + ) + } + } + +private fun buildPlayWith( + resources: Resources, + onClick: (Boolean, PlayerBackend?) -> Unit, +) = buildList { + val entries = + PlayerBackend.entries + .filterNot { it == PlayerBackend.UNRECOGNIZED } + .zip(resources.getStringArray(R.array.player_backend_options)) + .filterNot { it.first == PlayerBackend.PREFER_MPV } + entries.forEach { (backend, title) -> + add( + DialogItem( + title, + dismissOnClick = true, + ) { + onClick.invoke(false, backend) + }, + ) + } + add( + DialogItem( + resources.getString(R.string.transcoding), + dismissOnClick = true, + ) { + onClick.invoke(true, null) + }, + ) +} + +@Composable +fun ContextMenu( + onDismissRequest: () -> Unit, + item: ContextMenu.ForPerson, + actions: PersonContextActions, +) { + val person = item.person + val dialogItems = + buildList { + val itemId = person.id + add( + DialogItem( + stringResource(R.string.go_to), + Icons.Default.ArrowForward, + ) { + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) + }, + ) + add( + DialogItem( + text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (person.favorite) Color.Red else Color.Unspecified, + ) { + actions.onClickFavorite.invoke(itemId, !person.favorite) + }, + ) + } + DialogPopup( + showDialog = true, + title = person.name ?: "", + dialogItems = dialogItems, + onDismissRequest = onDismissRequest, + dismissOnClick = true, + waitToLoad = item.fromLongClick, + ) +} + +@Composable +fun ContextMenu( + onDismissRequest: () -> Unit, + item: ContextMenu.ForMusic, + actions: MusicContextActions, +) { + val resources = LocalResources.current + var showDeleteDialog by remember { mutableStateOf(false) } + val dialogItems = + remember(resources, item, actions) { + buildContextForMusic(resources, item, actions, onClickDelete = { + showDeleteDialog = true + }) + } + DialogPopup( + showDialog = true, + title = item.item.title ?: "", + dialogItems = dialogItems, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + waitToLoad = item.fromLongClick, + ) + if (showDeleteDialog) { + ConfirmDeleteDialog( + itemTitle = item.item.title ?: "", + onCancel = { showDeleteDialog = false }, + onConfirm = { + actions.onDeleteItem.invoke(item.item) + onDismissRequest.invoke() + }, + ) + } +} + +fun buildContextForMusic( + resources: Resources, + music: ContextMenu.ForMusic, + actions: MusicContextActions, + onClickDelete: () -> Unit, +): List<DialogItem> = + 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( + resources.getString(R.string.play), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.onClickPlay(index, item) + }, + ) + add( + DialogItem( + resources.getString(R.string.play_next), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.onClickPlayNext(index, item) + }, + ) + if (music.canRemoveFromQueue) { + add( + DialogItem( + resources.getString(R.string.remove_from_queue), + Icons.Default.Delete, + dismissOnClick = true, + ) { + actions.onClickRemoveFromQueue(index, item) + }, + ) + } else { + add( + DialogItem( + resources.getString(R.string.add_to_queue), + Icons.Default.Add, + dismissOnClick = true, + ) { + actions.onClickAddToQueue(item) + }, + ) + } + 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, + iconStringRes = R.string.fa_list_ul, + dismissOnClick = true, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + if (music.canDelete) { + add( + DialogItem( + resources.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + dismissOnClick = false, + ) { + onClickDelete.invoke() + }, + ) + } + add( + DialogItem( + text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (item.favorite) Color.Red else Color.Unspecified, + dismissOnClick = true, + ) { + actions.onClickFavorite.invoke(item.id, !item.favorite) + }, + ) + if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { + add( + DialogItem( + resources.getString(R.string.go_to_album), + R.string.fa_compact_disc, + dismissOnClick = true, + ) { + actions.onClickGoToAlbum.invoke(item.data.albumId!!) + }, + ) + } + if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { + add( + DialogItem( + resources.getString(R.string.go_to_artist), + R.string.fa_user, + dismissOnClick = true, + ) { + actions.onClickGoToArtist.invoke( + item.data.artistItems!! + .first() + .id, + ) + }, + ) + } + } + +@Composable +fun ContextMenu( + onDismissRequest: () -> Unit, + item: ContextMenu.ForQueue, + actions: QueueContextActions, +) { + val resources = LocalResources.current + val dialogItems = + remember(resources, item, actions) { + buildContextForMusicQueue( + resources, + item.item, + item.index, + actions, + ) + } + DialogPopup( + showDialog = true, + title = item.item.title ?: "", + dialogItems = dialogItems, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + waitToLoad = item.fromLongClick, + ) +} + +fun buildContextForMusicQueue( + resources: Resources, + item: AudioItem, + index: Int, + actions: QueueContextActions, +): List<DialogItem> = + buildList { + add( + DialogItem( + resources.getString(R.string.play), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.onClickPlay(index, item) + }, + ) + add( + DialogItem( + resources.getString(R.string.play_next), + Icons.Default.PlayArrow, + dismissOnClick = true, + ) { + actions.onClickPlayNext(index, item) + }, + ) + add( + DialogItem( + resources.getString(R.string.remove_from_queue), + Icons.Default.Delete, + dismissOnClick = true, + ) { + actions.onClickRemoveFromQueue(index, item) + }, + ) + if (item.albumId != null) { + add( + DialogItem( + resources.getString(R.string.go_to_album), + Icons.Default.ArrowForward, + dismissOnClick = true, + ) { + actions.onClickGoToAlbum.invoke(item.albumId) + }, + ) + } + if (item.artistId != null) { + add( + DialogItem( + resources.getString(R.string.go_to_artist), + Icons.Default.ArrowForward, + dismissOnClick = true, + ) { + actions.onClickGoToArtist.invoke(item.artistId) + }, + ) + } + } 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 d5897a6b..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,11 +1,13 @@ 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 import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +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 @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth 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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ 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 import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -55,15 +63,22 @@ 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.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +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 /** * Parameters for rendering a [DialogPopup] @@ -71,13 +86,16 @@ import org.jellyfin.sdk.model.api.MediaStreamType data class DialogParams( val fromLongClick: Boolean, val title: String, - val items: List<DialogItem>, + val items: List<DialogItemEntry>, ) sealed interface DialogItemEntry data object DialogItemDivider : DialogItemEntry +/** + * An item within a [DialogPopup] + */ data class DialogItem( val headlineContent: @Composable () -> Unit, val onClick: () -> Unit, @@ -87,11 +105,13 @@ data class DialogItem( val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, val selected: Boolean = false, + val dismissOnClick: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @StringRes iconStringRes: Int, iconColor: Color = Color.Unspecified, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -107,11 +127,13 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, @StringRes iconStringRes: Int, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -128,12 +150,14 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, icon: ImageVector, iconColor: Color? = null, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -150,10 +174,12 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) constructor( text: String, + dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -163,6 +189,7 @@ data class DialogItem( ) }, onClick = onClick, + dismissOnClick = dismissOnClick, ) companion object { @@ -184,7 +211,7 @@ fun DialogPopup( dismissOnClick: Boolean = true, waitToLoad: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, ) { var waiting by remember { mutableStateOf(waitToLoad) } if (showDialog) { @@ -204,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)), ) } } @@ -239,51 +273,58 @@ 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, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { - if (dismissOnClick) { + if (dismissOnClick || item.dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .lazyListWrapScrolling( + listState, + focused, + index == 0, + index == dialogItems.lastIndex, + focusRequesters[0], + focusRequesters[dialogItems.lastIndex], + ), ) } } @@ -298,7 +339,7 @@ fun DialogPopup( onDismissRequest: () -> Unit, dismissOnClick: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, ) = DialogPopup( showDialog = true, waitToLoad = params.fromLongClick, @@ -376,7 +417,7 @@ fun ScrollableDialog( fun BasicDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + elevation: Dp = 1.dp, content: @Composable () -> Unit, ) { Dialog( @@ -407,13 +448,16 @@ fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 8.dp, + 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) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -427,6 +471,9 @@ fun ConfirmDialogContent( onCancel: () -> Unit, 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), @@ -446,7 +493,7 @@ fun ConfirmDialogContent( item { Text( text = body, - color = MaterialTheme.colorScheme.onSurface, + color = bodyColor, ) } } @@ -457,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, ) } @@ -469,16 +516,94 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, + elevation: Dp = 3.dp, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = elevation, + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + 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() val videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } val title = source.name ?: source.path ?: source.id ?: "" @@ -486,8 +611,28 @@ fun chooseVersionParams( headlineContent = { Text(text = title) }, + leadingContent = { + 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) }, ) @@ -506,15 +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) { @@ -529,11 +677,24 @@ fun chooseStream( }, supportingContent = { }, + trailingContent = + if (currentIndex == null && streamChoice != null) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) add( DialogItem( + leadingContent = { + SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) + }, headlineContent = { Text(text = stringResource(R.string.only_forced_subtitles)) }, @@ -542,24 +703,49 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, ), ) + if (filteredStreams.isNotEmpty()) { + add(DialogItemDivider) + } } addAll( - streams.filter { it.type == type }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(context, stream, true) - DialogItem( - selected = currentIndex == stream.index, - leadingContent = { - SelectedLeadingContent(currentIndex == stream.index) - }, - headlineContent = { - Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) - }, - supportingContent = { - if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) - }, - onClick = { onClick.invoke(stream.index) }, - ) - }, + filteredStreams + .let { + if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { + it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } + } else { + it + } + }.mapIndexed { index, stream -> + val simpleStream = SimpleMediaStream.from(resources, stream, true) + DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, + headlineContent = { + Text( + 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/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt index a85d9fc9..47b4ebc9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt @@ -34,7 +34,6 @@ 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.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.google.protobuf.value +/** + * An input field for text customized for TV entry + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTextBox( 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 76c9e097..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,29 +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() } } } @@ -98,3 +93,13 @@ fun ErrorMessage( exception = error.exception, modifier = modifier, ) + +@Composable +fun ErrorMessage( + error: DataLoadingState.Error, + modifier: Modifier = Modifier, +) = ErrorMessage( + message = error.message, + exception = error.exception, + modifier = modifier, +) 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 a218b9c7..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 @@ -46,6 +58,7 @@ 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 com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -55,6 +68,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID +/** + * Button for filtering data. + * + * Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked. + * + * @see GetItemsFilter + * @see ItemFilterBy + */ @Composable fun FilterByButton( filterOptions: List<ItemFilterBy<*>>, @@ -62,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) } @@ -70,7 +92,10 @@ fun FilterByButton( Box(modifier = modifier) { ExpandableFilterButton( filterCount = filterCount, - onClick = { dropDown = true }, + onClick = { + onShow?.invoke(true) + dropDown = true + }, modifier = Modifier, ) @@ -78,6 +103,7 @@ fun FilterByButton( expanded = dropDown, containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), onDismissRequest = { + onShow?.invoke(false) dropDown = false nestedDropDown = null }, @@ -198,7 +224,9 @@ fun FilterByButton( val isSelected = remember(currentValue) { when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { (currentValue as? List<UUID>) .orEmpty() .contains(value.value) @@ -263,7 +291,9 @@ fun FilterByButton( onClick = { val newFilter = when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { val list = (currentValue as? List<UUID>).orEmpty() val newValue = list @@ -409,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 new file mode 100644 index 00000000..811b0051 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt @@ -0,0 +1,78 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +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.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +/** + * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled. + */ +@Composable +@NonRestartableComposable +fun FocusableItemRow( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + isError: Boolean = false, +) = FocusableItemRow( + titleContent = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + }, + subtitleContent = { + Text( + text = subtitle, + style = MaterialTheme.typography.titleMedium, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + }, + modifier = modifier, +) + +@Composable +fun FocusableItemRow( + titleContent: @Composable () -> Unit, + subtitleContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val background by animateColorAsState( + if (focused) { + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Transparent + }, + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .padding(start = 8.dp) + .focusable(interactionSource = interactionSource) + .background(background, shape = RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + titleContent.invoke() + subtitleContent.invoke() + } +} 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 cdf18969..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 @@ -5,24 +5,23 @@ import androidx.compose.foundation.layout.Box 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 -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration 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.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter +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 import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -30,34 +29,39 @@ 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.nav.Destination -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.Dispatchers +import kotlinx.coroutines.CoroutineScope 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 import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours @HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel @@ -78,115 +82,190 @@ 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, Color.Black) + _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 = ConcurrentHashMap<UUID, String?>() - val semaphore = Semaphore(4) - genres - .map { genre -> - viewModelScope.async(Dispatchers.IO) { - semaphore.withPermit { - val item = - GetItemsRequestHandler - .execute( - api, - GetItemsRequest( - parentId = itemId, - recursive = true, - limit = 1, - sortBy = listOf(ItemSortBy.RANDOM), - fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.BACKDROP), - imageTypeLimit = 1, - includeItemTypes = includeItemTypes, - genreIds = listOf(genre.id), - enableTotalRecordCount = false, - ), - ).content.items - .firstOrNull() - if (item != null) { - genreToUrl[genre.id] = - imageUrlService.getItemImageUrl( - itemId = item.id, - itemType = item.type, - seriesId = null, - useSeriesForPrimary = true, - imageType = ImageType.BACKDROP, - imageTags = item.imageTags.orEmpty(), - fillWidth = cardWidthPx, - ) - } + 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, + ) } - } - }.awaitAll() - val genresWithImages = - genres.map { + _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, - ) - 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, +) + +private val genreCache by lazy { + InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) { + expireAfterWriteDuration = 2.hours + } +} + +/** + * Create a mapping from genre IDs to image URLs using random items within each genre + */ +suspend fun getGenreImageMap( + api: ApiClient, + userId: UUID?, + scope: CoroutineScope, + imageUrlService: ImageUrlService, + genres: List<UUID>, + parentId: UUID, + includeItemTypes: List<BaseItemKind>?, + cardWidthPx: Int?, + useCache: Boolean = true, +): Map<UUID, String?> { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } + val genreToUrl = ConcurrentHashMap<UUID, String?>() + val semaphore = Semaphore(4) + genres + .map { genreId -> + scope.async(WholphinDispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + userId = userId, + parentId = parentId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.BACKDROP), + imageTypeLimit = 1, + includeItemTypes = includeItemTypes, + genreIds = listOf(genreId), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { + genreToUrl[genreId] = + imageUrlService.getItemImageUrl( + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = true, + imageType = ImageType.BACKDROP, + imageTags = item.imageTags.orEmpty(), + fillWidth = cardWidthPx, + backdropTags = item.backdropImageTags.orEmpty(), + ) + } + } + } + }.awaitAll() + genreCache.put(key, genreToUrl) + return genreToUrl +} + +@Stable data class Genre( val id: UUID, val name: String, val imageUrl: String?, - val color: Color, + override val sortName: String = name, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false - override val sortName: String get() = name } +/** + * Show an optimized grid of genres for a library + */ @Composable 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) }, @@ -197,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 @@ -210,46 +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( - Destination.FilteredCollection( - itemId = itemId, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genre.name, - item?.title, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genre.id), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item.title, + includeItemTypes = includeItemTypes, + collectionType = collectionType, ), ) }, @@ -260,12 +328,12 @@ fun GenreCardGrid( showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, position -> }, columns = columns, spacing = spacing, - cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> GenreCard( genre = item, onClick = onClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt index 3598fb93..c697b667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt @@ -11,6 +11,11 @@ import androidx.tv.material3.Text private const val MAX_TO_SHOW = 4 +/** + * Display a comma separated list of genres + * + * Only the first few will be shown + */ @Composable fun GenreText( genres: List<String>, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt new file mode 100644 index 00000000..0ebdd1db --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +object HeaderUtils { + val topPadding = 48.dp + val bottomPadding = 32.dp + val startPadding = 8.dp + + val padding = PaddingValues(top = topPadding, bottom = bottomPadding, start = startPadding) + + val height = 180.dp + + val logoHeight = 60.dp + + val modifier = + Modifier + .padding(padding) + .height(height) +} 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 new file mode 100644 index 00000000..f86868bc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt @@ -0,0 +1,33 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +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.focus.onFocusChanged +import androidx.compose.ui.unit.dp + +/** + * An invisible, no size Box which can be focused. + * + * Used to allow for transitioning to restore hidden content + */ +@Composable +fun HiddenFocusBox( + focusRequester: FocusRequester = remember { FocusRequester() }, + onFocus: () -> Unit, +) = Box( + modifier = + Modifier + .fillMaxWidth() + .height(0.dp) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) onFocus.invoke() + }.focusable(), +) 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 1e274de6..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,148 +1,255 @@ 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 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) + 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() + 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, - cardContent = @Composable { item, onClick, onLongClick, mod -> + initialPosition = destination.initialPosition, + spacing = destination.viewOptions.spacing.dp, + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, onClick = onClick, 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/LicenseInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt index 67c56f12..6a2300c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt @@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +/** + * Displays dependencies' license information to comply with attribution + */ @Composable fun LicenseInfo(modifier: Modifier = Modifier) { val libraries by produceLibraries() 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/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt index 7417028f..874585de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt @@ -23,6 +23,9 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus +/** + * Show the overview text for an item. Uses a fixed size and allows for clicking. + */ @Composable fun OverviewText( overview: String, 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 17b84dee..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 @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup 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 import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,10 +21,12 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete 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 @@ -34,26 +40,32 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color 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 import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text 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 @@ -62,19 +74,35 @@ import kotlin.time.Duration.Companion.seconds */ @Composable fun ExpandablePlayButtons( + title: String, resumePosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, trailers: List<Trailer>?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, 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), @@ -83,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, @@ -104,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), ) } } @@ -151,6 +191,17 @@ fun ExpandablePlayButtons( ) } } + if (canDelete) { + item("delete") { + DeleteButton( + title = title, + onConfirmDelete = onConfirmDelete, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } // More button item("more") { @@ -163,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 @@ -234,7 +295,7 @@ fun ExpandablePlayButton( fun ExpandablePlayButton( @StringRes title: Int, resume: Duration, - icon: @Composable () -> Unit, + icon: @Composable BoxScope.() -> Unit, onClick: (position: Duration) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -254,12 +315,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - icon.invoke() + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -337,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(), @@ -359,11 +421,66 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onConfirmDelete: () -> Unit, + title: String, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + var showDeleteDialog by remember { mutableStateOf(false) } + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { showDeleteDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDeleteDialog) { + ConfirmDeleteDialog( + itemTitle = title, + onCancel = { showDeleteDialog = false }, + onConfirm = { + onConfirmDelete.invoke() + showDeleteDialog = false + }, + ) + } +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { WholphinTheme(true) { ExpandablePlayButtons( + title = "Movie", resumePosition = 10.seconds, watched = false, favorite = false, @@ -374,6 +491,9 @@ private fun ExpandablePlayButtonsPreview() { buttonOnFocusChanged = {}, trailers = listOf(), trailerOnClick = {}, + canDelete = true, + onConfirmDelete = {}, + chooseVersionParams = null, modifier = Modifier, ) } @@ -417,12 +537,21 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onConfirmDelete = {}, + title = "Movie", + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onConfirmDelete = {}, + title = "Movie", + interactionSource = source, + ) } } } 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 cf5b0697..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.ui.TimeFormatter +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock +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 = TimeFormatter.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 24b3d502..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,122 +1,349 @@ 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 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.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.HomeRowViewOptions +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.MediaReportService +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.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 +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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +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 kotlinx.coroutines.Dispatchers +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.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.model.api.MediaType +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 java.util.UUID -abstract class RecommendedViewModel( - val context: Context, - val navigationManager: NavigationManager, - val favoriteWatchManager: FavoriteWatchManager, - val mediaReportService: MediaReportService, - private val backdropService: BackdropService, -) : ViewModel() { - abstract fun init() +@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 + } - abstract val rows: MutableStateFlow<List<HomeRowLoadingState>> + private val _state = MutableStateFlow(RecommendedState()) + val state: StateFlow<RecommendedState> = _state - val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + fun init() { + viewModelScope.launchDefault { + 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) } + } + } + } + } - 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) + 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 setWatched( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setWatched(itemId, watched) - refreshItem(position, itemId) - } - } +data class RecommendedState( + val loading: LoadingState = LoadingState.Pending, + val rows: List<HomeRowLoadingState> = emptyList(), +) - 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, - ) - - fun update( - @StringRes title: Int, - block: suspend () -> List<BaseItem>, - ) { - viewModelScope.launch(Dispatchers.IO) { - val titleStr = context.getString(title) - val row = - try { - HomeRowLoadingState.Success(titleStr, block.invoke()) - } catch (ex: Exception) { - HomeRowLoadingState.Error(titleStr, null, ex) - } - update(title, row) - } - } -} +data class RecommendedRow<T>( + val title: Int, + val handler: RequestHandler<T>, + val request: T, +) @Composable fun RecommendedContent( @@ -126,78 +353,121 @@ fun RecommendedContent( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), onFocusPosition: ((RowColumn) -> Unit)? = null, ) { - val context = LocalContext.current - var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } + 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) + ErrorMessage(st, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { + var position by rememberPosition() + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel.navigationManager::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = { viewModel.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 + }, + ) + } + HomePageContent( - homeRows = rows, + homeRows = state.rows, + position = position, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { position, item -> - moreDialog.makePresent(RowColumnItem(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, + ) }, onClickPlay = { _, item -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) }, - onFocusPosition = onFocusPosition, + onFocusPosition = { + position = it + val nonEmptyRowBefore = + state.rows + .subList(0, it.row) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition?.invoke( + RowColumn( + it.row - nonEmptyRowBefore, + it.column, + ), + ) + }, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = true, + onClickViewMore = viewModel::onClickViewMore, modifier = modifier, ) } } - moreDialog.compose { (position, item) -> - DialogPopup( - showDialog = true, - title = item.title ?: "", - dialogItems = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - actions = - MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickWatch = { itemId, watched -> - viewModel.setWatched(position, itemId, watched) - }, - onClickFavorite = { itemId, watched -> - viewModel.setFavorite(position, itemId, watched) - }, - onClickAddPlaylist = { - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(it) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ), - ), - onDismissRequest = { moreDialog.makeAbsent() }, - dismissOnClick = true, - waitToLoad = true, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog.compose { itemId -> @@ -214,12 +484,8 @@ fun RecommendedContent( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } - -private 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 e44c72f7..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,247 +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.MediaReportService -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.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, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val preferencesDataStore: DataStore<AppPreferences>, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - ) : RecommendedViewModel( - context, - navigationManager, - favoriteWatchManager, - mediaReportService, - backdropService, - ) { - @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) - } - } - - 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) - } - - 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) - } - - 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) - } - - 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: 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) { - loading.setValueOnMain(LoadingState.Success) - } - } - } - - override fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ) { - rows.update { current -> - current.toMutableList().apply { set(rowTitles[title]!!, 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 @@ -252,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 new file mode 100644 index 00000000..73e9af44 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -0,0 +1,108 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.UserPreferences +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.util.GetItemsRequestHandler +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 java.time.LocalDateTime +import java.util.UUID + +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( + preferences: UserPreferences, + parentId: UUID, + onFocusPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + 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( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) +} 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 4e952033..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,295 +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.MediaReportService -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.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, - private val api: ApiClient, - private val 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, - ) : RecommendedViewModel( - context, - navigationManager, - favoriteWatchManager, - mediaReportService, - backdropService, - ) { - @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) - } - } - - 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) - } - - 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) - } - - 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) - } - - 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: 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) { - loading.setValueOnMain(LoadingState.Success) - } - } - } - - override fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ) { - rows.update { current -> - current.toMutableList().apply { set(rowTitles[title]!!, 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 @@ -300,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/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt index efc2ddac..d06fd652 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -13,6 +13,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.tv.material3.LocalContentColor +/** + * Shows a dot indicator + * + * Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem] + */ @Composable fun BoxScope.SelectedLeadingContent( selected: Boolean, 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 64cfc78b..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 @@ -126,16 +126,19 @@ data class SliderColors( @Composable fun default() = SliderColors( - activeFocused = SliderActiveColor(true), - activeUnfocused = SliderActiveColor(false), - inactiveFocused = SliderInactiveColor(true), - inactiveUnfocused = SliderInactiveColor(false), + activeFocused = sliderActiveColor(true), + activeUnfocused = sliderActiveColor(false), + inactiveFocused = sliderInactiveColor(true), + inactiveUnfocused = sliderInactiveColor(false), ) } } +/** + * Determines the active color for the slider. This is the "filled" left side of the slider + */ @Composable -fun SliderActiveColor(focused: Boolean): Color { +fun sliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, @@ -143,6 +146,8 @@ fun SliderActiveColor(focused: Boolean): Color { AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, + AppThemeColors.RED, + AppThemeColors.BROWN, -> { MaterialTheme.colorScheme.border } @@ -165,8 +170,11 @@ fun SliderActiveColor(focused: Boolean): Color { } } +/** + * Determines the inactive color for the slider. This is background of "unfilled" right side of the slider. + */ @Composable -fun SliderInactiveColor(focused: Boolean): Color { +fun sliderInactiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, @@ -182,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 new file mode 100644 index 00000000..b69e1126 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalConfiguration +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.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 +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +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.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.WholphinDispatchers +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 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) +class StudioViewModel + @AssistedInject + constructor( + private val api: ApiClient, + private val imageUrlService: ImageUrlService, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + @Assisted private val itemId: UUID, + @Assisted private val includeItemTypes: List<BaseItemKind>?, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + includeItemTypes: List<BaseItemKind>?, + ): StudioViewModel + } + + private val _state = MutableStateFlow(StudioGridState()) + val state: StateFlow<StudioGridState> = _state + + fun init(cardWidthPx: Int) { + _state.update { it.copy(item = DataLoadingState.Loading) } + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) + } + 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(WholphinDispatchers.IO) { + GetStudiosRequestHandler.countMatching( + api = api, + request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ), + ) + } + } + +@Stable +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 +} + +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) }, + ), +) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + 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 + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } + OneTimeLaunchedEffect { + viewModel.init(cardWidthPx) + } + val state by viewModel.state.collectAsState() + + val gridFocusRequester = remember { FocusRequester() } + when (val st = state.item) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Error -> { + ErrorMessage(st, modifier.focusable()) + } + + is DataLoadingState.Success<BaseItem> -> { + Box(modifier = modifier) { + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + CardGrid( + pager = state.studios, + onClickItem = { _, studio -> + viewModel.navigationManager.navigateTo( + createStudioDestination( + studioId = studio.id, + name = studio.name, + parentId = itemId, + parentName = st.data.title, + includeItemTypes = includeItemTypes, + ), + ) + }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + letterPosition = { viewModel.positionOfLetter(it) }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = true, + modifier = Modifier.fillMaxSize(), + initialPosition = initialPosition, + positionCallback = { columns, position -> + }, + columns = columns, + spacing = spacing, + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + StudioCard( + studio = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + } + } +} 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 25b51698..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,14 +14,16 @@ 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 -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -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 @@ -40,26 +41,31 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.ifElse 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 currentOnClick by rememberUpdatedState(onClick) + LazyRow( state = state, modifier = @@ -71,35 +77,114 @@ fun TabRow( onEnter = { // If entering from left or right, use last or first tab // Otherwise use the selected tab - Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex") - val focusRequester = + val index = currentSelectedTabIndex +// val requesters = currentFocusRequesters + Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") + val tab = if (requestedFocusDirection == FocusDirection.Left) { - focusRequesters.lastOrNull() + tabs.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - focusRequesters.firstOrNull() + tabs.firstOrNull() } else { - focusRequesters.getOrNull(selectedTabIndex) + 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) { + { + currentOnClick(index) + } + } Tab( - title = tabTitle, + title = tab.title.getString(), selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, - onClick = { - onClick.invoke(index) - }, - modifier = Modifier.focusRequester(focusRequesters[index]), + onClick = onTabClick, + 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, @@ -185,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/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index f5791e04..f70a4f6a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -12,6 +12,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.util.LocalClock +/** + * Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box] + */ @Composable fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { val timeString by LocalClock.current.timeString 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 new file mode 100644 index 00000000..4268f2d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -0,0 +1,138 @@ +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 +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.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.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 + +@Composable +fun TitleOrLogo( + title: String?, + logoImageUrl: String?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + var imageError by remember { mutableStateOf(false) } + Box( + modifier = modifier.heightIn(max = HeaderUtils.logoHeight), + ) { + if (showLogo && logoImageUrl != null && !imageError) { + AsyncImage( + model = logoImageUrl, + contentDescription = title, + contentScale = ContentScale.Fit, + onError = { + logCoilError(logoImageUrl, it.result) + imageError = true + }, + modifier = + Modifier + .height(HeaderUtils.logoHeight) + .widthIn(max = 320.dp), + ) + } else { + Title(title, Modifier) + } + } +} + +@Composable +private fun Title( + title: String?, + modifier: Modifier = Modifier, +) { + Text( + text = title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Composable +fun TitleOrLogo( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + val logoImageUrl = rememberLogoUrl(item) + TitleOrLogo( + title = item?.title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = modifier, + ) +} + +@Composable +fun rememberLogoUrl(item: BaseItem?): String? { + val imageUrlService = LocalImageUrlService.current + return remember(item?.id) { + 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 { + null + } + } +} + +@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 e4e6d350..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,14 +41,21 @@ 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.GRID_OPTIONS] + */ @Composable fun ViewOptionsDialog( viewOptions: ViewOptions, onDismissRequest: () -> Unit, onViewOptionsChange: (ViewOptions) -> Unit, defaultViewOptions: ViewOptions = ViewOptions(), + showArtistSource: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -61,50 +72,76 @@ 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 + * + * This is used to determine how the UI looks such as card size and shape + */ @Serializable data class ViewOptions( val columns: Int = 6, @@ -112,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 = @@ -166,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, @@ -192,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, + ) } } @@ -239,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 009c4268..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,38 +2,50 @@ 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 +/** + * A supplementary [ViewModel] for adding items to a server playlist + * @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog + */ @HiltViewModel 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) } } } @@ -43,7 +55,13 @@ class AddPlaylistViewModel itemId: UUID, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - playlistCreator.addToServerPlaylist(playlistId, itemId) + try { + playlistCreator.addToServerPlaylist(playlistId, itemId) + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) + } catch (ex: Exception) { + Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId) + showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT) + } } } @@ -55,7 +73,15 @@ class AddPlaylistViewModel val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId)) if (playlistId == null) { showToast(context, "Error creating playlist", Toast.LENGTH_LONG) + } else { + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) } } } + + 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 fa32c595..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,15 +13,19 @@ 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 import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.byteRateSuffixes +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.studioNames 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 @@ -33,6 +37,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.extensions.ticks +import java.time.LocalDateTime import java.util.Locale data class ItemDetailsDialogInfo( @@ -40,15 +45,29 @@ data class ItemDetailsDialogInfo( val overview: String?, val genres: List<String>, val files: List<MediaSourceInfo>, -) + val studios: List<String> = emptyList(), + val lastPlayed: LocalDateTime? = null, +) { + constructor(item: BaseItem) : this( + title = item.name ?: "", + overview = item.data.overview, + genres = item.data.genres.orEmpty(), + files = item.data.mediaSources.orEmpty(), + studios = item.studioNames, + lastPlayed = item.data.userData?.lastPlayedDate, + ) +} +/** + * Dialog showing metadata about an item + */ @Composable fun ItemDetailsDialog( info: ItemDetailsDialogInfo, 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) @@ -58,6 +77,7 @@ fun ItemDetailsDialog( val bitrateLabel = stringResource(R.string.bitrate) val unknown = stringResource(R.string.unknown) val runtimeLabel = stringResource(R.string.runtime_sort) + val lastPlayedLabel = stringResource(R.string.last_played) ScrollableDialog( onDismissRequest = onDismissRequest, @@ -71,6 +91,12 @@ fun ItemDetailsDialog( text = info.title, style = MaterialTheme.typography.headlineSmall, ) + if (info.studios.isNotEmpty()) { + Text( + text = info.studios.joinToString(", "), + style = MaterialTheme.typography.titleMedium, + ) + } if (info.genres.isNotEmpty()) { Text( text = info.genres.joinToString(", "), @@ -83,6 +109,14 @@ fun ItemDetailsDialog( style = MaterialTheme.typography.bodyMedium, ) } + val lastPlayed = + remember(info.lastPlayed) { info.lastPlayed?.let { formatDate(it) } } + if (lastPlayed != null) { + Text( + text = "$lastPlayedLabel: $lastPlayed", + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -116,11 +150,11 @@ fun ItemDetailsDialog( } source.bitrate?.let { add( - bitrateLabel to formatBytes(it, byteRateSuffixes), + bitrateLabel to formatBitrate(it), ) } source.runTimeTicks?.let { - add(runtimeLabel to it.ticks.toString()) + add(runtimeLabel to resources.formatDuration(it.ticks)) } }, ) @@ -131,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) }, ) } @@ -151,7 +185,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, audioStreams.size, ), - items = buildAudioStreamInfo(context, stream), + items = buildAudioStreamInfo(resources, stream), modifier = Modifier.weight(1f), ) } @@ -177,7 +211,7 @@ fun ItemDetailsDialog( groupIndex * 3 + index, subtitleStreams.size, ), - items = buildSubtitleStreamInfo(context, stream, showFilePath), + items = buildSubtitleStreamInfo(resources, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -268,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()) } @@ -297,7 +331,7 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } @@ -317,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) } @@ -361,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 @@ -379,52 +413,52 @@ 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) } stream.channels?.let { add(channelsLabel to it.toString()) } stream.profile?.let { add(profileLabel to it) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } } 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 17f3d0ee..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, @@ -99,6 +114,45 @@ val BoxSetSortOptions = ItemSortBy.RANDOM, ) +val AlbumSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val ArtistSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.RANDOM, + ) + +val SongSortOptions = + listOf( + ItemSortBy.SORT_NAME, + ItemSortBy.PREMIERE_DATE, + ItemSortBy.ALBUM, + ItemSortBy.ALBUM_ARTIST, + ItemSortBy.ARTIST, + ItemSortBy.DATE_CREATED, + ItemSortBy.DATE_PLAYED, + ItemSortBy.COMMUNITY_RATING, + ItemSortBy.CRITIC_RATING, + ItemSortBy.PLAY_COUNT, + ItemSortBy.RUNTIME, + ItemSortBy.RANDOM, + ) + @StringRes fun getStringRes(sort: ItemSortBy): Int = when (sort) { @@ -132,5 +186,39 @@ fun getStringRes(sort: ItemSortBy): Int = ItemSortBy.DEFAULT -> R.string.default_track + ItemSortBy.ALBUM -> R.string.album + + ItemSortBy.ALBUM_ARTIST -> R.string.album_artist + + ItemSortBy.ARTIST -> R.string.artist + 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 8893246c..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 @@ -1,9 +1,12 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -11,6 +14,7 @@ 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.padding @@ -24,12 +28,14 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -47,8 +53,8 @@ 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.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.layout.onLayoutRectChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -59,20 +65,19 @@ import androidx.tv.material3.LocalContentColor 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.AppColors import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton 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 +import kotlin.math.ceil private const val DEBUG = false @@ -82,6 +87,18 @@ interface CardGridItem { val sortName: String } +data class GridItemDetails<T : CardGridItem>( + val item: T?, + val index: Int, + val onClick: () -> Unit, + val onLongClick: () -> Unit, + val widthPx: Int, + val mod: Modifier, +) + +/** + * Shows a vertical grid of [CardGridItem]s + */ @OptIn(ExperimentalFoundationApi::class) @Composable fun <T : CardGridItem> CardGrid( @@ -96,30 +113,21 @@ fun <T : CardGridItem> CardGrid( modifier: Modifier = Modifier, initialPosition: Int = 0, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - cardContent: @Composable ( - item: T?, - onClick: () -> Unit, - onLongClick: () -> Unit, - mod: Modifier, - ) -> Unit = { item, onClick, onLongClick, mod -> - GridCard( - item = item as BaseItem?, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = ContentScale.FillBounds, - modifier = mod, - ) - }, + cardContent: @Composable (GridItemDetails<T>) -> Unit, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { - val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) + val startPosition = + remember(initialPosition, pager.size) { + initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) + } - val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + var focusedIndex by rememberSaveable { mutableIntStateOf(startPosition) } + val currentFocusedIndex by rememberUpdatedState(focusedIndex) val gridState = rememberLazyGridState( - cacheWindow = fractionCacheWindow, + cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f), initialFirstVisibleItemIndex = focusedIndex, ) val scope = rememberCoroutineScope() @@ -128,13 +136,16 @@ fun <T : CardGridItem> CardGrid( var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } var alphabetFocus by remember { mutableStateOf(false) } - val focusOn = { index: Int -> - if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index") - if (index != focusedIndex) { - previouslyFocusedIndex = focusedIndex + val focusOn = + remember { + { index: Int -> + if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") + if (index != currentFocusedIndex) { + previouslyFocusedIndex = currentFocusedIndex + } + focusedIndex = index + } } - focusedIndex = index - } // Wait for a recomposition to focus val alphabetFocusRequester = remember { FocusRequester() } @@ -173,27 +184,52 @@ fun <T : CardGridItem> CardGrid( } } - val jump = { jump: Int -> - scope.launch(ExceptionHandler()) { - val newPosition = - (gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size) - if (DEBUG) Timber.d("newPosition=$newPosition") - focusOn(newPosition) - gridState.scrollToItem(newPosition, 0) - } - } - val jumpToTop = { - scope.launch(ExceptionHandler()) { - if (focusedIndex < (columns * 6)) { - // If close, animate the scroll - gridState.animateScrollToItem(0, 0) - } else { - gridState.scrollToItem(0, 0) + val jump = + remember { + { jump: Int -> + scope.launch(ExceptionHandler()) { + val newPosition = + (gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size) + if (DEBUG) Timber.d("newPosition=$newPosition") + focusOn(newPosition) + gridState.scrollToItem(newPosition, 0) + } + } + } + val jumpToTop = + remember { + { + scope.launch(ExceptionHandler()) { + if (currentFocusedIndex < (columns * 6)) { + // If close, animate the scroll + gridState.animateScrollToItem(0, 0) + } else { + gridState.scrollToItem(0, 0) + } + focusOn(0) + zeroFocus.tryRequestFocus() + } + } + } + + val jumpToLetter: (Char) -> Unit = + remember { + { letter: Char -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(WholphinDispatchers.IO) { + letterPosition.invoke(letter) + } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true + } + } } - focusOn(0) - zeroFocus.tryRequestFocus() } - } if (pager.isEmpty()) { Box( @@ -221,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 @@ -253,6 +290,12 @@ fun <T : CardGridItem> CardGrid( } else if (useJumpRemoteButtons && isBackwardButton(it)) { jump(-jump1) return@onKeyEvent true + } else if (showLetterButtons && pager.isNotEmpty() && + it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) + ) { + val letter = it.nativeKeyEvent.unicodeChar.toChar() + jumpToLetter.invoke(letter) + return@onKeyEvent true } else { return@onKeyEvent false } @@ -266,108 +309,124 @@ fun <T : CardGridItem> CardGrid( modifier = Modifier.align(Alignment.CenterVertically), ) } + val density = LocalDensity.current + var cardWidthPx by rememberSaveable { mutableIntStateOf(0) } + Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position -// focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition - } - } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) - } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { -// savedFocusedIndex = index -// // Was focused on this, so mark unfocused -// focusedIndex = -1 - } - }, - ) - } - } - if (pager.isEmpty()) { -// focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) - } - } - if (showFooter) { - // Footer - Box( + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 16.dp), modifier = Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } + } + }.onLayoutRectChanged(0, 0) { + val width = it.width + val spacingPx = with(density) { spacing.toPx() } + val cardWidth = + ceil((width - (spacingPx * (columns - 1))) / columns) + cardWidthPx = cardWidth.toInt() +// Timber.v("cardWidthPx=%s", cardWidthPx) + }, ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + items(pager.size) { index -> + val item = pager[index] + val details = + remember(index, item, cardWidthPx, columns) { + val mod = + if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + GridItemDetails( + item = item, + index = index, + onClick = { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + onLongClick = { + if (item != null) { + onLongClickItem.invoke(index, item) + } + }, + widthPx = cardWidthPx, + mod = + mod + .ifElse( + index == 0, + Modifier.focusRequester(zeroFocus), + ).onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } + }, + ) + } + cardContent.invoke(details) + } + } + if (pager.isEmpty()) { +// focusedIndex = -1 + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } + } + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) + val letters = stringResource(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -377,12 +436,10 @@ fun <T : CardGridItem> CardGrid( ?.firstOrNull() ?.uppercaseChar() ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null } } ?: letters[0] @@ -396,27 +453,23 @@ fun <T : CardGridItem> CardGrid( .align(Alignment.CenterVertically) .padding(start = 16.dp), // Add end padding to push away from edge - letterClicked = { letter -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(Dispatchers.IO) { - letterPosition.invoke(letter) - } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true - } - } - }, + letterClicked = jumpToLetter, + ) + } else { + // Spacer ensures card sizes do not change if the alphabet buttons are not shown + Spacer( + Modifier + .padding(start = 16.dp) + .width(letterHorizontalPadding * 2 + letterButtonSize), ) } } } } +private val letterHorizontalPadding = 2.dp +private val letterButtonSize = 14.dp + @Composable fun JumpButtons( jump1: Int, @@ -462,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 @@ -470,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) } } @@ -483,7 +536,7 @@ fun AlphabetButtons( var alphabetPickerFocused by remember { mutableStateOf(false) } LazyColumn( - contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = 2.dp), + contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding), verticalArrangement = Arrangement.spacedBy(1.1.dp), state = listState, modifier = @@ -517,14 +570,14 @@ fun AlphabetButtons( Box( modifier = Modifier - .size(14.dp) + .size(letterButtonSize) .clip(CircleShape) .alpha(itemAlpha), ) { Button( modifier = Modifier - .size(14.dp) + .size(letterButtonSize) .focusRequester(focusRequesters[index]), contentPadding = PaddingValues(0.dp), // No padding to maximize text space interactionSource = interactionSource, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt deleted file mode 100644 index 07c24c37..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ /dev/null @@ -1,48 +0,0 @@ -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.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.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import java.util.UUID - -@Composable -fun CollectionFolderBoxSet( - preferences: UserPreferences, - itemId: UUID, - recursive: Boolean, - modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), - preferencesViewModel: PreferencesViewModel = hiltViewModel(), - playEnabled: Boolean = false, -) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, - itemId = itemId, - initialFilter = filter, - showTitle = showHeader, - recursive = recursive, - sortOptions = BoxSetSortOptions, - initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - defaultViewOptions = ViewOptionsPoster, - playEnabled = playEnabled, - ) -} 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 dc426409..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,29 +1,16 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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 @@ -33,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 @@ -59,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, @@ -89,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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - 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 96ddbe94..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,43 +1,32 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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( @@ -46,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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - 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( @@ -102,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()) @@ -129,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()) @@ -161,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, ) } @@ -175,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 new file mode 100644 index 00000000..9ab7e33f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -0,0 +1,268 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.foundation.layout.fillMaxSize +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.compose.ui.focus.focusRequester +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.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.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.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.nav.Destination +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 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 + +@HiltViewModel(assistedFactory = CollectionFolderMusicViewModel.Factory::class) +class CollectionFolderMusicViewModel + @AssistedInject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val musicService: MusicService, + private val navigationManager: NavigationManager, + val backdropService: BackdropService, + @Assisted private val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): CollectionFolderMusicViewModel + } + + fun play(item: BaseItem) { + if (item.type == BaseItemKind.AUDIO) { + viewModelScope.launchDefault { + musicService.setQueue(listOf(item), false) + } + } + } + + fun onClick( + index: Int, + item: BaseItem, + ) { + navigationManager.navigateTo(item.destination()) + } + + fun onClickPlayAll(shuffle: Boolean) { + viewModelScope.launchDefault { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + includeItemTypes = listOf(BaseItemKind.AUDIO), + recursive = true, + ) + val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + musicService.setQueue(pager, 0, shuffle) + } + } + + fun onClickPlayRemoteButton( + index: Int, + item: BaseItem, + ) { + if (item.type == BaseItemKind.AUDIO) { + viewModelScope.launchDefault { + musicService.setQueue(listOf(item), false) + } + } + } + } + +@Composable +fun CollectionFolderMusic( + preferences: UserPreferences, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: CollectionFolderMusicViewModel = + hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>( + creationCallback = { it.create(destination.itemId) }, + ), +) { + val tabs = + 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 = + remember { + GridClickActions( + onClickItem = viewModel::onClick, + onLongClickItem = null, + onClickPlayAll = viewModel::onClickPlayAll, + onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, + ) + } + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { + // Recommended + 0 -> { + RecommendedMusic( + preferences = preferences, + parentId = destination.itemId, + onFocusPosition = { pos -> + showHeader = pos.row < 1 + }, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Albums + 1 -> { + CollectionFolderView( + preferences = preferences, + actions = actions, + itemId = destination.itemId, + viewModelKey = "${destination.itemId}_albums", + initialFilter = + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + ), + ), + showTitle = false, + recursive = true, + sortOptions = AlbumSortOptions, + defaultViewOptions = ViewOptionsSquare, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = true, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } + + // Artists + 2 -> { + CollectionFolderView( + preferences = preferences, + actions = actions, + itemId = destination.itemId, + viewModelKey = "${destination.itemId}_artists", + initialFilter = + CollectionFolderFilter( + filter = + GetItemsFilter( + override = GetItemsFilterOverride.ARTIST, + ), + ), + showTitle = false, + recursive = true, + sortOptions = ArtistSortOptions, + defaultViewOptions = ViewOptionsSquare, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } + + // Genres + 3 -> { + GenreCardGrid( + itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + collectionType = CollectionType.MUSIC, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Songs + 4 -> { + CollectionFolderView( + preferences = preferences, + actions = actions, + itemId = destination.itemId, + viewModelKey = "${destination.itemId}_songs", + initialFilter = + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.AUDIO), + ), + ), + showTitle = false, + recursive = true, + sortOptions = SongSortOptions, + defaultViewOptions = ViewOptionsSquare, + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = true, + focusRequesterOnEmpty = tabDetails.tabFocusRequester, + ) + } + + else -> { + 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 fd4d46d3..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,21 +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 @@ -48,25 +49,34 @@ fun CollectionFolderPhotoAlbum( ) }, ) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + var showHeader by rememberSaveable { mutableStateOf(true) } + val state by viewModel.state.collectAsState() + CollectionFolderView( preferences = preferences, - onClickItem = { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO) { - Destination.Slideshow( - parentId = itemId, - index = index, - filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), - sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, - recursive = true, - startSlideshow = false, - ) - } else { - item.destination(index) - } - viewModel.navigationManager.navigateTo(destination) - }, + actions = + remember { + GridClickActions( + onClickItem = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = itemId, + index = index, + filter = + CollectionFolderFilter( + filter = state.filter, + ), + sortAndDirection = state.sortAndDirection, + recursive = recursive, + startSlideshow = false, + ) + } else { + item.destination(index) + } + viewModel.navigateTo(destination) + }, + ) + }, itemId = itemId.toServerString(), initialFilter = filter, showTitle = showHeader, 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 d72001ba..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,100 +1,77 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.TabRow +import com.github.damontecres.wholphin.ui.components.StudioCardGrid +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), - ) - 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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - 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( @@ -106,13 +83,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } // Library 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, itemId = destination.itemId, initialFilter = @@ -125,19 +102,18 @@ fun CollectionFolderTv( showTitle = false, recursive = true, sortOptions = SeriesSortOptions, + filterOptions = DefaultTvFilterOptions, defaultViewOptions = ViewOptionsPoster, 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, ) } @@ -146,15 +122,28 @@ fun CollectionFolderTv( GenreCardGrid( itemId = destination.itemId, includeItemTypes = listOf(BaseItemKind.SERIES), + collectionType = CollectionType.TVSHOWS, modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + // Studios + 3 -> { + StudioCardGrid( + itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.SERIES), + 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/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 0abcdc07..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 @@ -1,20 +1,34 @@ 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 @@ -23,131 +37,252 @@ 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.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.RefreshRateService +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 refreshRateService: RefreshRateService, + 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 display: Display by lazy { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + displayManager.getDisplay(Display.DEFAULT_DISPLAY) + } + + val av1Included by lazy { + try { + Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") + true + } catch (_: ClassNotFoundException) { + false + } + } + + val ffmpegIncluded by lazy { + try { + Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer") + true + } catch (_: ClassNotFoundException) { + false + } + } + + val libMpvLoaded by lazy { + try { + System.loadLibrary("player") + System.loadLibrary("mpv") + true + } catch (_: Exception) { + false + } + } 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}") - } - } } } @@ -156,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() @@ -197,144 +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 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}", - "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.refreshRateService.supportedDisplayModes, - ).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()) } } } @@ -343,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", @@ -397,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 cebfff70..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ /dev/null @@ -1,360 +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.ArrowForward -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Info -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.data.model.Person -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.letNotEmpty -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 org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -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, -) - -enum class ClearChosenStreams { - NONE, - ITEM_AND_SERIES, - SERIES, -} - -/** - * Build the [DialogItem]s when clicking "More" - * - * If there are multiple versions, adds an option to pick one - * - * If there is more than one (ie two or more) audio track, adds an option to pick one - * - * If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one - * - * @param item the media item to build for, typically an Episode or Movie - * @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option - * @param sourceId the item's media source UUID - * @param navigateTo a function to trigger a navigation - * @param onChooseVersion callback to pick a version of the item - * @param onChooseTracks callback to pick a track for the given type of the item - * @param onShowOverview callback to show overview dialog with media information - */ -fun buildMoreDialogItems( - context: Context, - item: BaseItem, - seriesId: UUID?, - sourceId: UUID?, - watched: Boolean, - favorite: Boolean, - canClearChosenStreams: Boolean, - actions: MoreDialogActions, - onChooseVersion: () -> Unit, - onChooseTracks: (MediaStreamType) -> Unit, - onShowOverview: () -> Unit, - onClearChosenStreams: () -> Unit, -): List<DialogItem> = - buildList { - add( - DialogItem( - context.getString(R.string.play), - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - actions.navigateTo( - Destination.Playback( - item.id, - item.resumeMs ?: 0L, - ), - ) - }, - ) - item.data.mediaSources?.letNotEmpty { sources -> - val source = - sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } - ?: sources.firstOrNull() - source?.mediaStreams?.letNotEmpty { streams -> - val audioCount = streams.count { it.type == MediaStreamType.AUDIO } - val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE } - if (audioCount > 1) { - add( - DialogItem( - context.getString( - R.string.choose_stream, - context.getString(R.string.audio), - ), - R.string.fa_volume_low, - ) { - onChooseTracks.invoke(MediaStreamType.AUDIO) - }, - ) - } - if (subtitleCount > 0) { - add( - DialogItem( - context.getString( - R.string.choose_stream, - context.getString(R.string.subtitles), - ), - R.string.fa_closed_captioning, - ) { - onChooseTracks.invoke(MediaStreamType.SUBTITLE) - }, - ) - } - } - if (sources.size > 1) { - add( - DialogItem( - context.getString( - R.string.choose_stream, - context.getString(R.string.version), - ), - R.string.fa_file_video, - ) { - onChooseVersion.invoke() - }, - ) - } - } - add( - DialogItem( - text = R.string.add_to_playlist, - iconStringRes = R.string.fa_list_ul, - ) { - actions.onClickAddPlaylist.invoke(item.id) - }, - ) - 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(item.id, !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(item.id, !favorite) - }, - ) - seriesId?.let { - add( - DialogItem( - context.getString(R.string.go_to_series), - Icons.AutoMirrored.Filled.ArrowForward, - ) { - actions.navigateTo( - Destination.MediaItem( - seriesId, - BaseItemKind.SERIES, - null, - ), - ) - }, - ) - } - if (item.data.mediaSources?.isNotEmpty() == true) { - add( - DialogItem( - context.getString(R.string.media_information), - Icons.Default.Info, - ) { - onShowOverview.invoke() - }, - ) - } - if (canClearChosenStreams) { - add( - DialogItem( - context.getString(R.string.clear_track_choices), - Icons.Default.Delete, - ) { - onClearChosenStreams() - }, - ) - } - add( - DialogItem( - context.getString(R.string.play_with_transcoding), - Icons.Default.PlayArrow, - ) { - actions.navigateTo( - Destination.Playback( - item.id, - item.resumeMs ?: 0L, - forceTranscoding = true, - ), - ) - }, - ) - add( - DialogItem( - text = R.string.send_media_info_log_to_server, - iconStringRes = R.string.fa_file_video, - ) { - actions.onSendMediaInfo.invoke(item.id) - }, - ) - } - -fun buildMoreDialogItemsForHome( - context: Context, - item: BaseItem, - seriesId: UUID?, - playbackPosition: Duration, - watched: Boolean, - favorite: Boolean, - actions: MoreDialogActions, -): List<DialogItem> = - buildList { - val itemId = item.id - add( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.navigateTo(item.destination()) - }, - ) - 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, - ), - ) - }, - ) - } - } - add( - DialogItem( - text = R.string.add_to_playlist, - iconStringRes = R.string.fa_list_ul, - ) { - actions.onClickAddPlaylist.invoke(itemId) - }, - ) - 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) - }, - ) - } - -fun buildMoreDialogItemsForPerson( - context: Context, - person: Person, - actions: MoreDialogActions, -): List<DialogItem> = - buildList { - val itemId = person.id - add( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) - }, - ) - add( - DialogItem( - text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (person.favorite) Color.Red else Color.Unspecified, - ) { - actions.onClickFavorite.invoke(itemId, !person.favorite) - }, - ) - } 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 fc337d88..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,318 +1,129 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.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.GridClickActions +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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - 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, + ) + } } - // TODO playEnabled = true for movies & episodes - when (selectedTabIndex) { - // Movies - 0 -> { - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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), - ) - } - // TV - 1 -> { - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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), - ) - } + 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, + ) - // Episodes - 2 -> { - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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, - onClickItem = { _, item -> onClickItem.invoke(item) }, - 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 5a01d8a3..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,19 +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 @@ -89,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)) } } } } @@ -140,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), @@ -163,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 }, + ) } } } @@ -282,6 +340,8 @@ fun PersonPageContent( overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + onClickViewMoreDiscover: () -> Unit, + enableViewMoreDiscover: Boolean, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -324,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, @@ -337,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, @@ -369,6 +429,7 @@ fun PersonPageContent( }, onLongClick = onLongClick, imageHeight = Cards.heightEpisode, + aspectRatio = item?.aspectRatio ?: AspectRatios.FOUR_THREE, modifier = mod .ifElse( @@ -384,8 +445,9 @@ fun PersonPageContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), + DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = RowColumn(DISCOVER_ROW, index) @@ -394,6 +456,11 @@ fun PersonPageContent( onLongClickItem = { _, _ -> }, onCardFocus = {}, focusRequester = focusRequester, + enableViewMore = enableViewMoreDiscover, + onClickViewMore = { + position = RowColumn(DISCOVER_ROW, discovered.size) + onClickViewMoreDiscover.invoke() + }, ) } } @@ -463,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, ) })" @@ -493,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 57c8c5d6..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,650 +1,755 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable +import android.content.Context 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.ArrowForward -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.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.MutableLiveData +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.services.BackdropService +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +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.DefaultItemFields -import com.github.damontecres.wholphin.ui.TimeFormatter -import com.github.damontecres.wholphin.ui.cards.ItemCardImage -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.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.components.FilterByButton -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SortByButton -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +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.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.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.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.formatDateTime -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.equalsNotNull +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.setValueOnMain -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils +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.util.LocalClock 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.successValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +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.distinctUntilChanged +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.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 javax.inject.Inject -import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel - @Inject + @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, + @ApplicationContext context: Context, api: ApiClient, - val navigationManager: NavigationManager, - private val backdropService: BackdropService, + navigationManager: NavigationManager, + musicService: MusicService, + mediaManagementService: MediaManagementService, private val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - ) : ItemViewModel(api) { - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val items = MutableLiveData<List<BaseItem?>>(listOf()) - val filterAndSort = - MutableStateFlow<FilterAndSort>( - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - ), - ) + @Assisted itemId: UUID, + @Assisted private val collectionState: StateFlow<CollectionFolderState>, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + collectionState: StateFlow<CollectionFolderState>, + ): PlaylistViewModel + } - fun init(playlistId: UUID) { - loading.value = LoadingState.Loading - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), - ) { - val playlist = fetchItem(playlistId) - 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) + 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() { + viewModelScope.launchDefault { + try { + 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 info for %s", itemId) +// _state.update { it.copy(item = DataLoadingState.Error(ex)) } + } } } - fun loadItems( - filter: GetItemsFilter, - sortAndDirection: SortAndDirection, + /** + * This method tries to determine the [MediaType] of a playlist + * + * In theory, the server will set the type, but sometimes it doesn't + */ + 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 + + else -> MediaType.UNKNOWN + } + } + if (types.keys.size == 1) { + types.keys.first() + } else { + MediaType.UNKNOWN + } + } else { + MediaType.UNKNOWN + } + } else { + mediaType + } + Timber.d("mediaType=%s", mediaType) + _state.update { + it.copy(mediaType = mediaType) + } + } + } + + fun removeFromPlaylist( + index: Int, + itemId: UUID, ) { viewModelScope.launchIO { - backdropService.clearBackdrop() - loading.setValueOnMain(LoadingState.Loading) - this@PlaylistViewModel.filterAndSort.update { - FilterAndSort(filter, sortAndDirection) - } - - serverRepository.currentUser.value?.let { user -> - val playlistId = item.value!!.id - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - - val request = - filter.applyTo( - GetItemsRequest( - parentId = playlistId, - userId = user.id, - fields = DefaultItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), - ) - try { - val pager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - ).init() - - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - withContext(Dispatchers.Main) { - items.value = listOf() - loading.value = LoadingState.Error(ex) - } - } + 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}") } } } - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser.value?.id, - itemUuid, - filterOption, - ) - - fun updateBackdrop(item: BaseItem) { + fun onMoveItem( + index: Int, + itemId: UUID, + direction: MoveDirection, + ) { viewModelScope.launchIO { - backdropService.submit(item) + 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 mediaType: MediaType = MediaType.UNKNOWN, + val canEdit: Boolean = false, ) @Composable fun PlaylistDetails( + preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PlaylistViewModel = hiltViewModel(), -) { - val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(destination.itemId) - } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val playlist by viewModel.item.observeAsState(null) - val items by viewModel.items.observeAsState(listOf()) - val filterAndSort by viewModel.filterAndSort.collectAsState() - - var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } - - val goToString = stringResource(R.string.go_to) - val playFromHereString = stringResource(R.string.play_from_here) - - PlaylistDetailsContent( - loadingState = loading, - playlist = playlist, - items = items, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) - }, - onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = 0, - shuffle = shuffle, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) - }, - onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - listOf( - DialogItem( - goToString, - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - playFromHereString, - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = false, - filter = filterAndSort.filter, - sortAndDirection = filterAndSort.sortAndDirection, - ), - ) - }, + 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, ) - }, - filterAndSort = filterAndSort, - onFilterAndSortChange = viewModel::loadItems, - getPossibleFilterValues = viewModel::getFilterOptionValues, - modifier = modifier, - ) - longClickDialog?.let { params -> - DialogPopup( - params = params, - onDismissRequest = { longClickDialog = null }, - ) - } -} - -@Composable -fun PlaylistDetailsContent( - playlist: BaseItem?, - items: List<BaseItem?>, - 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, + }, + ), + viewModel: PlaylistViewModel = + hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>( + creationCallback = { it.create(destination.itemId, collectionViewModel.state) }, + ), + addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - 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 state by collectionViewModel.state.collectAsState() + val playlistState by viewModel.state.collectAsState() + val musicState by viewModel.musicState.collectAsState() + val focusManager = LocalFocusManager.current - val playButtonFocusRequester = remember { FocusRequester() } + 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.collectAsState() - Box( - modifier = modifier, + fun play( + index: Int, + item: BaseItem, + shuffle: Boolean, + mediaTypeOverride: MediaType? = null, ) { - 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 (mediaTypeOverride ?: playlistState.mediaType) { + MediaType.VIDEO -> { + collectionViewModel.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = shuffle, + filter = state.filter, + sortAndDirection = state.sortAndDirection, + ), ) - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) - } + } - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } + MediaType.AUDIO -> { + viewModel.play(item, index, shuffle) + } - 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) - } - }, - modifier = - 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(), - ) - } - } - } - } - } + else -> { + showConfirmTypeDialog = Triple(index, item, shuffle) } } } -} -@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, + fun showContextMenu( + index: Int, + item: BaseItem, + fromLongClick: Boolean, ) { - 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, + 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 { + 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) } }, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier.focusRequester(focusRequester), - ) - SortByButton( - sortOptions = BoxSetSortOptions, - current = filterAndSort.sortAndDirection, - onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, + onClickPlayRemoteButton = { index, item -> play(index, item, false) }, ) } - 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, + 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 }, ) - } - OverviewText( - overview = focusedItem?.data?.overview ?: "", - maxLines = 10, - onClick = {}, - enabled = false, + }, + ) + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showConfirmTypeDialog?.let { (index, item, shuffle) -> + ConfirmMediaTypeDialog( + onConfirm = { mediaType -> play(index, item, shuffle, mediaType) }, + onCancel = { showConfirmTypeDialog = null }, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = addPlaylistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + addToPlaylistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + onSearch = addToPlaylistViewModel::loadPlaylists, + elevation = 3.dp, ) } } @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() - ListItem( - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.enableMarquee(focused), - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - style = MaterialTheme.typography.titleSmall, - 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) - TimeFormatter.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 { + 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 = 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 = duration.toString(), + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, ) + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + contentHeight = contentHeight, + ) { Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + 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, - ) - 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(160.dp), - useFallbackText = false, + text = stringResource(R.string.fa_ellipsis_vertical), + fontFamily = FontAwesome, ) } - }, - modifier = modifier, - ) + } + } } + +@Composable +fun ConfirmMediaTypeDialog( + onConfirm: (MediaType) -> Unit, + onCancel: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(), + elevation = 3.dp, + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentSize(), + ) { + item { + Text( + text = stringResource(R.string.play_as_type), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillParentMaxWidth(), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton( + stringRes = R.string.audio, + onClick = { onConfirm.invoke(MediaType.AUDIO) }, + ) + TextButton( + stringRes = R.string.video, + onClick = { onConfirm.invoke(MediaType.VIDEO) }, + ) + } + } + } + } +} + +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/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt new file mode 100644 index 00000000..759da02e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +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.GetItemsFilter +import com.github.damontecres.wholphin.ui.components.DeleteButton +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.SortByButton +import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlin.time.Duration + +@Composable +fun CollectionButtons( + state: CollectionState, + onSortChange: (SortAndDirection) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, + onClickViewOptions: () -> Unit, + favoriteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = MovieSortOptions + val filterOptions = DefaultFilterOptions + val firstFocus = remember { FocusRequester() } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier, + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlayAll.invoke(false) }, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlayAll.invoke(true) }, + ) + } + + item("favorite") { + val favorite = remember(state.collection) { state.collection?.favorite == true } + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = favoriteOnClick, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier, + ) + } + if (canDelete) { + item("delete") { + DeleteButton( + title = state.collection?.title ?: "", + onConfirmDelete = onConfirmDelete, + modifier = Modifier, + ) + } + } + + item { + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickViewOptions, + modifier = Modifier, + ) + } + + // More button + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { moreOnClick.invoke() }, + modifier = Modifier, + ) + } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup(), + ) { + item { + SortByButton( + sortOptions = sortOptions, + current = state.sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + item { + FilterByButton( + filterOptions = filterOptions, + current = state.itemFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + } + } +} 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 new file mode 100644 index 00000000..291ef74a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -0,0 +1,458 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +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.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +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 com.github.damontecres.wholphin.R +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.preferences.UserPreferences +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.HeaderUtils +import com.github.damontecres.wholphin.ui.components.HiddenFocusBox +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.ItemDetailsDialog +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.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 timber.log.Timber +import java.util.UUID + +@Composable +fun CollectionDetails( + preferences: UserPreferences, + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: CollectionViewModel = + hiltViewModel<CollectionViewModel, CollectionViewModel.Factory>( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + + // Dialogs + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + var showViewOptionsDialog by remember { mutableStateOf(false) } + var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } + + fun contextActionsFor(position: RowColumn?) = + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, position) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, position) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = { viewModel.deleteItem(it, position) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + ) + + // Actions + val onClickItem = + remember { + { _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) } + } + val onLongClickItem = + remember { + { position: RowColumn, item: BaseItem -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActionsFor(position), + ) + } + } + val onSortChange = + remember { + { sort: SortAndDirection -> viewModel.changeSort(sort) } + } + val onFilterChange = + remember { + { filter: GetItemsFilter -> viewModel.changeFilter(filter) } + } + val onClickPlay = { _: RowColumn, item: BaseItem -> + viewModel.navigateTo(Destination.Playback(item = item)) + } + val onClickPlayAll = + remember { + { shuffle: Boolean -> + val dest = + Destination.PlaybackList( + itemId = itemId, + startIndex = 0, + shuffle = shuffle, + recursive = true, + sortAndDirection = state.sortAndDirection, + filter = state.itemFilter, + ) + viewModel.navigateTo(dest) + } + } + val onClickViewOptions = remember { { showViewOptionsDialog = true } } + + when (val s = state.loadingState) { + is LoadingState.Error -> { + ErrorMessage(s, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + CollectionDetailsContent( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onSortChange = onSortChange, + onClickPlay = onClickPlay, + onClickPlayAll = onClickPlayAll, + onChangeBackdrop = viewModel::updateBackdrop, + onFilterChange = onFilterChange, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + letterPosition = viewModel::letterPosition, + onClickViewOptions = onClickViewOptions, + modifier = modifier, + overviewOnClick = { + val collection = state.collection!! + overviewDialog = ItemDetailsDialogInfo(collection) + }, + favoriteOnClick = + remember { + { + state.collection?.let { + viewModel.setFavorite(it.id, !it.favorite, null) + } + } + }, + onConfirmDelete = + remember { + { + state.collection?.let { + viewModel.deleteItem(it, null) + } + } + }, + canDelete = + remember( + state.collection, + preferences.appPreferences.interfacePreferences.enableMediaManagement, + ) { + state.collection?.let { + viewModel.canDelete(it, preferences.appPreferences) + } ?: false + }, + moreOnClick = { + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = false, + item = state.collection!!, + chosenStreams = null, + showGoTo = false, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + state.collection!!, + preferences.appPreferences, + ), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActionsFor(null), + ) + }, + ) + } + } + if (showViewOptionsDialog) { + CollectionViewOptionsDialog( + viewOptions = state.viewOptions, + onDismissRequest = { showViewOptionsDialog = false }, + onViewOptionsChange = viewModel::changeViewOptions, + ) + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto + ?.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() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} + +@Composable +fun CollectionDetailsContent( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onSortChange: (SortAndDirection) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, + letterPosition: suspend (Char) -> Int, + onClickViewOptions: () -> Unit, + overviewOnClick: () -> Unit, + favoriteOnClick: () -> Unit, + onConfirmDelete: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier, +) { + var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val focusRequester = remember { FocusRequester() } + val contentFocusRequester = remember { FocusRequester() } + + var focusedItem by remember { mutableStateOf<BaseItem?>(state.collection) } + LaunchedEffect(focusedItem) { + focusedItem?.let { onChangeBackdrop.invoke(it) } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + SharedTransitionLayout { + AnimatedContent( + targetState = itemsContentHasFocus, + label = "header_transition", + ) { targetState -> + if (targetState) { + // Show item header + LaunchedEffect(Unit) { + contentFocusRequester.tryRequestFocus() + } + Column( + Modifier.sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ), + ) { + // This box exists so that there is something focusable above the item content + // allowing focus to move up to restore the collection's header + HiddenFocusBox { + itemsContentHasFocus = false + } + if (state.viewOptions.cardViewOptions.showDetails) { + HomePageHeader( + item = focusedItem, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + modifier = + Modifier + .padding( + start = HeaderUtils.startPadding, + top = HeaderUtils.topPadding, + bottom = 8.dp, + ).height(HeaderUtils.height), + ) + } + } + } else { + // Show collection header + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + focusedItem = state.collection + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { -it / 2 } + fadeIn(), + exit = slideOutVertically { -it / 2 } + fadeOut(), + ).padding(bottom = 16.dp) + .fillMaxWidth() + .onFocusChanged { + if (it.hasFocus) { + onChangeBackdrop.invoke(state.collection!!) + } + }, + ) { + CollectionDetailsHeader( + collection = state.collection!!, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + logoImageUrl = state.logoImageUrl, + overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, + modifier = + Modifier + .padding( + top = HeaderUtils.topPadding, + bottom = HeaderUtils.bottomPadding, + ).height(HeaderUtils.height), + ) + CollectionButtons( + state = state, + onSortChange = onSortChange, + onClickPlayAll = onClickPlayAll, + onFilterChange = onFilterChange, + getPossibleFilterValues = getPossibleFilterValues, + onClickViewOptions = onClickViewOptions, + favoriteOnClick = favoriteOnClick, + onConfirmDelete = onConfirmDelete, + canDelete = canDelete, + moreOnClick = moreOnClick, + modifier = + Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + } + } + } + } + Box( + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + if (it.hasFocus) itemsContentHasFocus = true + }.focusProperties { + up = focusRequester + }.focusRequester(contentFocusRequester), + ) { + if (state.viewOptions.separateTypes) { + CollectionRows( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { position -> +// Timber.v("onFocusPosition=%s", position) + focusedItem = + position.let { + val key = + state.separateItems.keys + .toList() + .getOrNull(it.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull( + it.column, + ) + } + }, + ) + } else { + CollectionMixedGrid( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { + Timber.v("onFocusPosition=%s", it) + focusedItem = state.items.getOrNull(it.column) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt new file mode 100644 index 00000000..cbdf1560 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +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.runtime.Composable +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.text.font.FontStyle +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.data.model.BaseItem +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.letNotEmpty +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch + +@Composable +fun CollectionDetailsHeader( + collection: BaseItem, + showLogo: Boolean, + logoImageUrl: String?, + overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, + modifier: Modifier = Modifier, +) { + val dto = collection.data + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + TitleOrLogo( + title = collection.name, + showLogo = showLogo, + logoImageUrl = logoImageUrl, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = HeaderUtils.startPadding), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + QuickDetails( + collection.ui.quickDetails, + collection.timeRemainingOrRuntime, + Modifier.padding(start = HeaderUtils.startPadding), + ) + + dto.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) + } + dto.taglines?.firstOrNull()?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) + } + + // Description + dto.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + } + } +} 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 new file mode 100644 index 00000000..057c6db2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +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.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec +import org.jellyfin.sdk.model.api.ItemSortBy + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionMixedGrid( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + letterPosition: suspend (Char) -> Int, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + val gridFocusRequester = remember { FocusRequester() } + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize(), + ) { + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current + + val cardViewOptions = state.viewOptions.cardViewOptions + CardGrid( + pager = state.items, + onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) }, + onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) }, + onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) }, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = + Modifier + .fillMaxSize(), + initialPosition = 0, + positionCallback = { _, newPosition -> + onFocusPosition.invoke(RowColumn(0, newPosition)) + }, + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = cardViewOptions.contentScale.scale, + imageAspectRatio = cardViewOptions.aspectRatio.ratio, + imageType = cardViewOptions.imageType, + showTitle = cardViewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + columns = cardViewOptions.columns, + spacing = cardViewOptions.spacing.dp, + bringIntoViewSpec = + remember(cardViewOptions, density) { + val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } + if (cardViewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, + ) + } + } +} 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 new file mode 100644 index 00000000..126a5765 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -0,0 +1,119 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import org.jellyfin.sdk.model.api.BaseItemKind + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionRows( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + var position by rememberPosition(0, 0) + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxSize() + .padding(top = 8.dp), + ) { + val cardViewOptions = state.viewOptions.cardViewOptions + val homeRows = + remember(state.separateItems, cardViewOptions) { + state.separateItems.map { (type, row) -> + if (row is HomeRowLoadingState.Success) { + // TODO not great to do this in the UI + val viewOptions = + 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 + } + } + } + HomePageContent( + homeRows = homeRows, + position = position, + onFocusPosition = { newPosition -> + position = newPosition + onFocusPosition.invoke(newPosition) + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + showClock = false, + onUpdateBackdrop = {}, + 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 new file mode 100644 index 00000000..bdbb059a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -0,0 +1,528 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.content.Context +import androidx.compose.runtime.Stable +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.ImageUrlService +import com.github.damontecres.wholphin.services.KeyValueService +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.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.collectLatestIn +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +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.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +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.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +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.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class) +class CollectionViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val serverRepository: ServerRepository, + private val navigationManager: NavigationManager, + private val preferencesService: UserPreferencesService, + private val themeSongPlayer: ThemeSongPlayer, + private val mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val keyValueService: KeyValueService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val imageUrlService: ImageUrlService, + private val musicService: MusicService, + val serverReportService: ServerReportService, + private val filterOptionCache: FilterOptionCache, + @Assisted private val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): CollectionViewModel + } + + private val viewOptionsFlow = + serverRepository.currentUserFlow + .filterNotNull() + .flatMapLatest { + keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) + + private val libraryDisplayInfoFlow = + serverRepository.currentUserFlow + .filterNotNull() + .flatMapLatest { + libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + private val _state = MutableStateFlow(CollectionState()) + val state: StateFlow<CollectionState> = _state + + init { + addCloseable { release() } + // Get global per-user view options for collections + viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions -> + Timber.v("Updated viewOptions") + _state.update { + it.copy(viewOptions = viewOptions) + } + } + libraryDisplayInfoFlow + .filterNotNull() + .collectLatestIn(viewModelScope) { libraryDisplayInfo -> + Timber.v("Updated libraryDisplayInfo") + _state.update { + it.copy( + itemFilter = libraryDisplayInfo.filter, + sortAndDirection = libraryDisplayInfo.sortAndDirection, + ) + } + } + viewModelScope.launchDefault { + 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, + ) + } + 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)) } + } + } + } + + fun release() { + themeSongPlayer.stop() + } + + /** + * Collects on [state] and fetches data when needed + */ + private fun listenForStateUpdates() = + viewModelScope.launchDefault { + state + .map { + Triple( + it.sortAndDirection, + it.itemFilter, + it.viewOptions.separateTypes, + ) + }.distinctUntilChanged() + .collectLatest { (sort, filter, separateTypes) -> + try { + updateData(sort, filter, separateTypes) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching data for collection %s", + itemId, + ) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } + } + } + } + + private suspend fun updateData( + sort: SortAndDirection, + filter: GetItemsFilter, + separateTypes: Boolean, + ) { + Timber.d("Begin updateData for %s", itemId) + _state.update { + it.copy( + loadingState = LoadingState.Loading, + items = emptyList(), + separateItems = emptyMap(), + ) + } + if (!separateTypes) { + val result = fetchItems(sort, filter, typesInCollection) + _state.update { it.copy(items = result) } + } else { + supervisorScope { + val jobs = + typesInCollection.map { type -> + async(WholphinDispatchers.IO) { + val title = ResStringProvider(formatTypeName(type)) + val result = + try { + val pager = fetchItems(sort, filter, listOf(type)) + HomeRowLoadingState.Success( + title, + pager, + showViewMore = false, + ) + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching %s for collection %s", + type, + itemId, + ) + HomeRowLoadingState.Error(title, exception = ex) + } + type to result + } + } + jobs.forEach { job -> + val (type, row) = job.await() + // 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) + } + } + } + } + } + _state.update { it.copy(loadingState = LoadingState.Success) } + Timber.d("End updateData for %s", itemId) + } + + private suspend fun fetchItems( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List<BaseItemKind>, + ): ApiRequestPager<GetItemsRequest> { + val request = createGetItemsRequest(sort, filter, types) + val useSeriesForPrimary = !state.value.viewOptions.separateTypes + return ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ).init() + } + + private fun createGetItemsRequest( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List<BaseItemKind>, + ): GetItemsRequest { + val includeItemTypes: List<BaseItemKind>? + val excludeItemTypes: List<BaseItemKind>? + // Workaround for https://github.com/jellyfin/jellyfin/issues/16454 + if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) { + includeItemTypes = null + excludeItemTypes = + BaseItemKind.entries + .toMutableList() + .apply { remove(BaseItemKind.BOX_SET) } + } else { + includeItemTypes = types + excludeItemTypes = null + } + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + includeItemTypes = includeItemTypes, + excludeItemTypes = excludeItemTypes, + recursive = false, + sortBy = sort?.let { listOf(sort.sort) }, + sortOrder = sort?.let { listOf(sort.direction) }, + fields = SlimItemFields, + ).let { + filter?.applyTo(it, false) ?: it + } + return request + } + + fun changeSort(sortAndDirection: SortAndDirection) { + viewModelScope.launchIO { + val user = serverRepository.currentUser + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = state.itemFilter, + viewOptions = null, + ), + ) + } + } + } + + fun changeFilter(filter: GetItemsFilter) { + viewModelScope.launchIO { + val user = serverRepository.currentUser + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = state.sortAndDirection.sort, + direction = state.sortAndDirection.direction, + filter = filter, + viewOptions = null, + ), + ) + } + } + } + + fun changeViewOptions(viewOptions: CollectionViewOptions) { + viewModelScope.launchIO { + if (!viewOptions.cardViewOptions.showDetails) { + val collection = state.value.collection + if (collection != null) { + backdropService.submit(collection) + } else { + backdropService.clearBackdrop() + } + } + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) + } + } + } + + suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + filterOptionCache.getFilterOptionValues( + itemId, + filterOption, + ) + + suspend fun letterPosition(letter: Char): Int = + withContext(WholphinDispatchers.IO) { + val sort = state.value.sortAndDirection + val filter = state.value.itemFilter + val request = + createGetItemsRequest( + sort = sort, + filter = filter, + types = typesInCollection, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun setWatched( + itemId: UUID, + played: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { + favoriteWatchManager.setWatched(itemId, played) + if (itemId == state.value.collection?.id) { + refreshCollection() + } else if (position != null) { + refreshItem(itemId, position, false) + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + if (itemId == state.value.collection?.id) { + refreshCollection() + } else if (position != null) { + refreshItem(itemId, position, false) + } + } + + private fun refreshCollection() { + viewModelScope.launchDefault { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(collection = collection) } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem( + item: BaseItem, + position: RowColumn?, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchIO { + if (position != null) { + refreshItem(itemId, position, true) + } + } + } + } + + private suspend fun refreshItem( + itemId: UUID, + position: RowColumn, + isDelete: Boolean, + ) { + state.value.let { state -> + val items = + if (state.viewOptions.separateTypes) { + val key = + state.separateItems.keys + .toList() + .getOrNull(position.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*> + } else { + state.items as? ApiRequestPager<*> + } + if (isDelete) { + items?.refreshPagesAfter(position.column) + } else { + items?.refreshItem(position.column, itemId) + } + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchDefault { + val collection = state.value.collection + if (item.id == collection?.id) { + // Always show the collection's backdrop if requested + backdropService.submit(collection) + } else if (state.value.viewOptions.cardViewOptions.showDetails) { + backdropService.submit(item) + } else { + backdropService.clearBackdrop() + } + } + } + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + + companion object { + val typesInCollection = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_VIDEO, + ) + + const val VIEW_OPTIONS_KEY = "CollectionViewOptions" + } + } + +@Stable +data class CollectionState( + val loadingState: LoadingState = LoadingState.Pending, + val collection: BaseItem? = null, + val sortAndDirection: SortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + val itemFilter: GetItemsFilter = GetItemsFilter(), + val viewOptions: CollectionViewOptions = CollectionViewOptions(), + val items: List<BaseItem?> = emptyList(), + val separateItems: Map<BaseItemKind, HomeRowLoadingState> = emptyMap(), + val logoImageUrl: String? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt new file mode 100644 index 00000000..b080e817 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt @@ -0,0 +1,174 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +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 +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.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.text.style.TextAlign +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.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.serialization.Serializable + +@Composable +fun CollectionViewOptionsDialog( + viewOptions: CollectionViewOptions, + onDismissRequest: () -> Unit, + onViewOptionsChange: (CollectionViewOptions) -> Unit, + defaultViewOptions: CollectionViewOptions = CollectionViewOptions(), +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + val options = + if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + Column( + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ).padding(16.dp), + ) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(0.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + val pref = CollectionViewOptions.SeparateTypes + val interactionSource = remember { MutableInteractionSource() } + ComposablePreference( + preference = pref, + value = viewOptions.separateTypes, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = {}, + ) + } + items(options, key = { it.title }) { pref -> + pref as AppPreference<ViewOptions, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions.cardViewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + val newCardViewOptions = + pref.setter(viewOptions.cardViewOptions, newValue) + onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions)) + }, + interactionSource = interactionSource, + modifier = Modifier.animateItem(), + onClickPreference = { pref -> + if (pref == ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } + } + } + } +} + +@Serializable +data class CollectionViewOptions( + val separateTypes: Boolean = true, + val cardViewOptions: ViewOptions = + ViewOptions( + showDetails = true, + showTitles = true, + ), +) { + companion object { + val SeparateTypes = + AppSwitchPreference<CollectionViewOptions>( + title = R.string.separate_types, + defaultValue = false, + getter = { it.separateTypes }, + setter = { vo, value -> vo.copy(separateTypes = value) }, + ) + + val MixedOptions = + listOf( + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsColumns, + ViewOptionsSpacing, + ViewOptionsContentScale, + ViewOptionsReset, + ) + + val SeparateOptions = + listOf( + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsReset, + ) + } +} 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 7df86615..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,22 +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.detail.MoreDialogActions 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( @@ -80,127 +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) - - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> }, - onClickFavorite = { itemId, favorite -> }, - onClickAddPlaylist = { itemId -> }, - onSendMediaInfo = {}, - ) - - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) + when (val st = state.movie) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { - LoadingPage() + 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 }, ) } } @@ -212,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 @@ -236,7 +183,7 @@ fun DiscoverMovieDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, people: List<DiscoverItem>, trailers: List<Trailer>, @@ -254,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() } } @@ -283,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 = @@ -304,7 +251,7 @@ fun DiscoverMovieDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestMovies, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, @@ -351,7 +298,7 @@ fun DiscoverMovieDetailsContent( item = item, onClick = onClick, onLongClick = onLongClick, - showOverlay = false, + showOverlay = true, modifier = mod, ) }, @@ -408,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 cc80d404..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,121 +71,154 @@ 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 request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } + val requestEnabled = + seerrServerRepository.current.map { it?.requestMovieEnabled ?: false } + val request4kEnabled = + seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } init { 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 = DiscoverItem(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(::DiscoverItem) - .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(::DiscoverItem) - .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(::DiscoverItem) - .orEmpty() + + val people = movie.credits - ?.crew - ?.map(::DiscoverItem) - .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) + ?.cast + ?.map { seerrService.createDiscoverItem(it) } + .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() } @@ -184,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( @@ -201,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 32d38aa5..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(::DiscoverItem) - .orEmpty() - val crew = - credits.crew - ?.map(::DiscoverItem) - .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,12 +125,13 @@ fun DiscoverPersonPage( }, onClickPlay = { _, item -> }, + initialPosition = startIndex, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, showLetterButtons = false, spacing = 16.dp, - cardContent = @Composable { item, onClick, onLongClick, mod -> + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> DiscoverItemCard( item = item, onClick = onClick, 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 64748bd0..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,28 +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.Season 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 @@ -55,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 @@ -84,112 +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) + when (val st = state.tvSeries) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { - LoadingPage() + 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 -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = item.name ?: "", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), - ), - ) - } else { - viewModel.request(id, false) - } - } - }, - 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 -> @@ -218,6 +183,23 @@ fun DiscoverSeriesDetails( waitToLoad = params.fromLongClick, ) } + if (showRequestSeasonDialog) { + RequestSeasonsDialog( + id = state.tvSeries.successValue?.id ?: -1, + title = state.tvSeries.successValue?.name ?: "", + seasons = state.seasons, + seasons4k = state.seasons4k, + requestEnabled = requestEnabled, + request4kEnabled = request4kEnabled, + onSubmit = { + showRequestSeasonDialog = false + viewModel.request(it) + }, + loading = state.profileLoading, + data = state.requestData, + onDismissRequest = { showRequestSeasonDialog = false }, + ) + } } private const val HEADER_ROW = 0 @@ -232,9 +214,10 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, - rating: DiscoverRating?, + availability: SeerrAvailability, + rating: SourceRatings?, canCancel: Boolean, - seasons: List<Season>, + seasons: List<RequestSeason>, similar: List<DiscoverItem>, recommended: List<DiscoverItem>, trailers: List<Trailer>, @@ -269,7 +252,6 @@ fun DiscoverSeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) .fillMaxSize(), ) { LazyColumn( @@ -289,16 +271,16 @@ 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, moreOnClick = moreOnClick, goToOnClick = goToOnClick, @@ -310,7 +292,7 @@ fun DiscoverSeriesDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestTv, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, @@ -384,7 +366,7 @@ fun DiscoverSeriesDetailsContent( item = item, onClick = onClick, onLongClick = onLongClick, - showOverlay = false, + showOverlay = true, modifier = mod, ) }, @@ -442,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 @@ -471,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( @@ -500,7 +487,7 @@ fun DiscoverSeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, @@ -509,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) @@ -545,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 f7b11137..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,44 +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.Season +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) @@ -51,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() { @@ -59,137 +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<Season>>(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 = DiscoverItem(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) - 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(::DiscoverItem) - .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(::DiscoverItem) - .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(::DiscoverItem) - .orEmpty() + + val people = tv.credits - ?.crew - ?.map(::DiscoverItem) - .orEmpty() - this@DiscoverSeriesViewModel.people.setValueOnMain(people) + ?.cast + ?.map { seerrService.createDiscoverItem(it) } + .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 updateCanCancel() { - val user = userConfig.firstOrNull() - val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) - canCancelRequest.update { canCancel } + fun goTo( + mediaInfo: MediaInfo?, + type: BaseItemKind, + ) { + viewModelScope.launchDefault { + goToButtonDiscover(mediaInfo, type, context, navigationManager) + } } - fun request( - id: Int, + private suspend fun updateSeasonStatus(tv: TvDetails) { + if (request4kEnabled.first()) { + updateSeasonStatus(tv, true) + } + updateSeasonStatus(tv, false) + } + + private suspend fun updateSeasonStatus( + tv: TvDetails, is4k: Boolean, ) { + 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 + ) + } + } + } + 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) + } + } + } + + 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 { - val request = - seerrService.api.requestApi.requestPost( - RequestPostRequest( - is4k = is4k, - mediaId = id, - mediaType = RequestPostRequest.MediaType.TV, - seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons - ), - ) - fetchAndSetItem().await() - updateCanCancel() + 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 4b73a0b2..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 @@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons( trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + pendingOnClick: () -> Unit = {}, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -59,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) { @@ -73,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 -> { @@ -89,7 +110,7 @@ fun ExpandableDiscoverButtons( SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, -> { - // TODO? + pendingOnClick.invoke() } SeerrAvailability.PARTIALLY_AVAILABLE, @@ -98,7 +119,9 @@ fun ExpandableDiscoverButtons( goToOnClick.invoke() } - SeerrAvailability.DELETED -> { + SeerrAvailability.DELETED, + SeerrAvailability.BLOCKLISTED, + -> { // TODO } } @@ -109,6 +132,21 @@ fun ExpandableDiscoverButtons( .onFocusChanged(buttonOnFocusChanged), ) } + if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) { + item("request_partial") { + ExpandableFaButton( + title = R.string.request, + iconStringRes = R.string.fa_download, + onClick = { + requestOnClick.invoke() + }, + enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } if (canCancel) { item("cancel") { 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 new file mode 100644 index 00000000..91a10279 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -0,0 +1,487 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION +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.Row +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.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 +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Switch +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>, + seasons4k: List<RequestSeason>, + data: SeerrRequestData, + requestEnabled: Boolean, + request4kEnabled: Boolean, + onSubmit: (TvRequest) -> Unit, + modifier: Modifier = Modifier, +) { + 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 + .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(), + ) + } + 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(8.dp), + modifier = modifier, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier, + ) + LazyColumn( + modifier = Modifier, + ) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + if (requestEnabled && request4kEnabled) { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + Button( + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), + ) { + 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, + ) + } + } + } + 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 checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons + SeasonListItem( + season = season, + checked = checked, + onClick = { + if (checked) { + selectedSeasons.remove(seasonNumber) + } else { + seasonNumber?.let { selectedSeasons.add(it) } + } + }, + modifier = Modifier, + ) + } + if (seasons.size > 3) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Button( + onClick = ::submit, + enabled = selectedSeasons.isNotEmpty(), + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + } + } + } +} + +@Composable +fun SeasonListItem( + season: RequestSeason, + checked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + enabled = season.editable, + selected = false, + headlineContent = { + Text( + text = + season.season.name + ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } + ?: "", + ) + }, + supportingContent = { + season.season.episodeCount?.let { + Text( + // TODO should use plurals string + text = "${season.season.episodeCount} " + stringResource(R.string.episodes), + ) + } + }, + leadingContent = { + when (season.availability) { + SeerrAvailability.UNKNOWN, + SeerrAvailability.DELETED, + -> { + } + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator() + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator() + } + + SeerrAvailability.AVAILABLE -> { + AvailableIndicator() + } + + SeerrAvailability.BLOCKLISTED -> { + // TODO handle block listed + // BlocklistedIndicator() + } + } + }, + trailingContent = { + Row { + Switch( + checked = checked, + onCheckedChange = { + onClick.invoke() + }, + ) + } + }, + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun ClickSurface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + pressedContainerColor = MaterialTheme.colorScheme.inverseSurface, + pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + ), + onClick = onClick, + content = content, + modifier = modifier, + ) +} + +@Composable +fun ClickSwitch( + label: String, + checked: Boolean, + onClick: () -> Unit, +) { + ClickSurface( + onClick = onClick, + modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 8.dp) + .height(54.dp), + ) { + Switch( + checked = checked, + onCheckedChange = {}, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = label, + ) + } + } +} + +@Composable +fun RequestSeasonsDialog( + id: Int, + title: String, + loading: LoadingState, + data: SeerrRequestData, + seasons: List<RequestSeason>, + seasons4k: List<RequestSeason>, + requestEnabled: Boolean, + request4kEnabled: Boolean, + onSubmit: (TvRequest) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + 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), + ) + } + } + } +} + +@Preview( + device = "spec:parent=tv_1080p", + backgroundColor = 0xFF383535, + uiMode = UI_MODE_TYPE_TELEVISION, + heightDp = 800, +) +@Composable +fun RequestSeasonsPreview() { + val seasons = + List(10) { + RequestSeason( + season = + Season( + 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 = { }, + 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 bac9f51a..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 @@ -11,8 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn 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.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -30,31 +30,27 @@ 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.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ExpandablePlayButtons +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.components.chooseStream -import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems 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.MediaStreamType -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 org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -69,194 +65,132 @@ 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 moreDialog by remember { mutableStateOf<DialogParams?>(null) } - var chooseVersion by remember { mutableStateOf<DialogParams?>(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 moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ) + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage() - } - - LoadingState.Success -> { - item?.let { ep -> - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { item, source -> + viewModel.savePlayVersion( + item, + source.id!!.toUUID(), ) - onPauseOrDispose { - viewModel.release() - } + }, + onChooseTracks = { result -> + viewModel.saveTrackSelection( + result.item, + result.itemPlayback, + result.trackIndex, + result.streamType, + ) + }, + onClearChosenStreams = { viewModel.clearChosenStreams(it) }, + ) + } + + when (val st = state.episode) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) + } + + 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( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = ep.data.mediaSources.orEmpty(), - ) - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = ep.name + " (${ep.data.productionYear ?: ""})", - items = - buildMoreDialogItems( - context = context, - item = ep, - watched = ep.data.userData?.played ?: false, - favorite = ep.data.userData?.isFavorite ?: false, - seriesId = ep.data.seriesId, - sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - actions = moreActions, - onChooseVersion = { - chooseVersion = - chooseVersionParams( - context, - ep.data.mediaSources!!, - ) { idx -> - val source = ep.data.mediaSources!![idx] - viewModel.savePlayVersion( - ep, - source.id!!.toUUID(), - ) - } - moreDialog = null - }, - onChooseTracks = { type -> - viewModel.streamChoiceService - .chooseSource( - ep.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - currentIndex = - if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index - } else { - chosenStreams?.subtitleStream?.index - }, - type = type, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - ep, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } - }, - onShowOverview = { - val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull() - if (source != null) { - overviewDialog = - ItemDetailsDialogInfo( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = listOf(source), - ) - } - }, - onClearChosenStreams = { - viewModel.clearChosenStreams(chosenStreams) - }, - ), - ) - }, - watchOnClick = { - viewModel.setWatched(ep.id, !ep.played) - }, - favoriteOnClick = { - viewModel.setFavorite(ep.id, !ep.favorite) - }, - 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 }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } - chooseVersion?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { chooseVersion = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } showPlaylistDialog.compose { itemId -> @@ -273,6 +207,7 @@ fun EpisodeDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -290,6 +225,9 @@ fun EpisodeDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + canDelete: Boolean, + onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -304,7 +242,7 @@ fun EpisodeDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(bottom = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -324,9 +262,10 @@ fun EpisodeDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( + title = ep.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -347,6 +286,16 @@ fun EpisodeDetailsContent( }, trailers = null, 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 7808141e..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,7 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.episode import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams @@ -9,34 +8,38 @@ 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.MediaReportService +import com.github.damontecres.wholphin.services.MediaManagementService 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.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) @@ -49,11 +52,12 @@ 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, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -61,53 +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( + 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() } @@ -115,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() } @@ -126,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) } } } @@ -146,7 +162,6 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -154,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) } } @@ -188,15 +192,28 @@ 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) } + } } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } + +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 4276330d..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,73 +106,85 @@ 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)) } } } } + /** + * Creates a list of [LocalDateTime] for each hour from now + */ private fun buildGuideTimes() = buildList { val start = LocalDateTime.now().roundDownToHalfHour() @@ -184,32 +197,46 @@ 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)) } + } } + /** + * Get live TV programs for a subset of channels + * + * @param guideStart The start timestamp to fetch from + * @param channels The full list of channels + * @param channelIndices The indices of which channels to fetch programs for + */ private suspend fun fetchPrograms( guideStart: LocalDateTime, channels: List<TvChannel>, - range: IntRange, + channelIndices: IntRange, ) = mutex.withLock { val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1) val minEndDate = guideStart.plusMinutes(1L) - val channelsToFetch = channels.subList(range.first, range.last + 1) - Timber.v("Fetching programs for $range channels ${channelsToFetch.size}") + val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1) + Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}") val request = GetProgramsDto( maxStartDate = maxStartDate, 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 = @@ -272,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) { @@ -372,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( @@ -388,30 +420,30 @@ class LiveTvViewModel ), ) Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs") - withContext(Dispatchers.Main) { - this@LiveTvViewModel.programs.value = - FetchedPrograms(range, 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)) } } } } @@ -420,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}") } } } @@ -437,79 +474,82 @@ 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) } } private var focusLoadingJob: Job? = null + /** + * Callback when focusing on the EPG grid + * + * 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)) + }, + ) } } } @@ -528,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 @@ -553,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 @@ -574,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) } } } @@ -638,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 037a5ae7..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,9 +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 @@ -14,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 @@ -46,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 @@ -71,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, @@ -105,42 +100,53 @@ 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(focusedPosition.row < 1) { + AnimatedVisibility( + focusedPosition.row < 1, + enter = expandVertically(), + exit = shrinkVertically(), + ) { ExpandableFaButton( title = R.string.view_options, iconStringRes = R.string.fa_sliders, @@ -153,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 @@ -190,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() }, ) @@ -256,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 = @@ -270,7 +303,7 @@ val tvGuideDimensions = @Composable fun TvGuideGridContent( preferences: LiveTvPreferences, - loading: Boolean, + refreshing: Boolean, channels: List<TvChannel>, programs: FetchedPrograms, channelProgramCount: Map<UUID, Int>, @@ -278,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( @@ -587,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( @@ -599,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) + } + }, ) } @@ -617,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 23ed08c3..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,10 +13,10 @@ 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 +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -28,17 +28,13 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.Chapter 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 @@ -46,37 +42,34 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.ExpandablePlayButtons +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.components.chooseStream -import com.github.damontecres.wholphin.ui.components.chooseVersionParams +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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson 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.MediaStreamType -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 org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -96,255 +89,175 @@ fun MovieDetails( viewModel.init() onPauseOrDispose { } } - val item by viewModel.item.observeAsState() - val people by viewModel.people.observeAsState(listOf()) - val chapters by viewModel.chapters.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val extras by viewModel.extras.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val chosenStreams by viewModel.chosenStreams.observeAsState(null) - val discovered by viewModel.discovered.collectAsState() + val state by viewModel.state.collectAsState() var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } - var chooseVersion by remember { mutableStateOf<DialogParams?>(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 moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ) + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage() - } - - LoadingState.Success -> { - item?.let { movie -> - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { item, source -> + viewModel.savePlayVersion( + item, + source.id!!.toUUID(), ) - onPauseOrDispose { - viewModel.release() - } - } - MovieDetailsContent( - preferences = preferences, - movie = movie, - chosenStreams = chosenStreams, - people = people, - chapters = chapters, - extras = extras, - trailers = trailers, - similar = similar, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - movie.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.name ?: context.getString(R.string.unknown), - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - ) - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.name + " (${movie.data.productionYear ?: ""})", - items = - buildMoreDialogItems( - context = context, - item = movie, - watched = movie.data.userData?.played ?: false, - favorite = movie.data.userData?.isFavorite ?: false, - seriesId = null, - sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - actions = moreActions, - onChooseVersion = { - chooseVersion = - chooseVersionParams( - context, - movie.data.mediaSources!!, - ) { idx -> - val source = movie.data.mediaSources!![idx] - viewModel.savePlayVersion( - movie, - source.id!!.toUUID(), - ) - } - moreDialog = null - }, - onChooseTracks = { type -> - - viewModel.streamChoiceService - .chooseSource( - movie.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - currentIndex = - if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index - } else { - chosenStreams?.subtitleStream?.index - }, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - movie, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } - }, - onShowOverview = { - overviewDialog = - ItemDetailsDialogInfo( - title = - movie.name - ?: context.getString(R.string.unknown), - overview = movie.data.overview, - genres = movie.data.genres.orEmpty(), - files = movie.data.mediaSources.orEmpty(), - ) - }, - onClearChosenStreams = { - viewModel.clearChosenStreams(chosenStreams) - }, - ), - ) - }, - watchOnClick = { - viewModel.setWatched(movie.id, !movie.played) - }, - favoriteOnClick = { - viewModel.setFavorite(movie.id, !movie.favorite) - }, - onLongClickPerson = { index, person -> - val items = - buildMoreDialogItemsForPerson( - context = context, - person = person, - actions = moreActions, - ) - moreDialog = - DialogParams( - fromLongClick = true, - title = person.name ?: "", - items = items, - ) - }, - onLongClickSimilar = { index, similar -> - val items = - buildMoreDialogItemsForHome( - context = context, - item = similar, - seriesId = null, - playbackPosition = similar.playbackPosition, - watched = similar.played, - favorite = similar.favorite, - actions = moreActions, - ) - moreDialog = - DialogParams( - fromLongClick = true, - title = similar.title ?: "", - items = items, - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { index, extra -> - viewModel.navigateTo(extra.destination) - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - modifier = modifier, - ) - } + }, + onChooseTracks = { result -> + viewModel.saveTrackSelection( + result.item, + result.itemPlayback, + result.trackIndex, + result.streamType, + ) + }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onClearChosenStreams = { viewModel.clearChosenStreams(it) }, + ) } + + when (val s = state.loading) { + is DataLoadingState.Error -> { + ErrorMessage(s, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) + } + + is DataLoadingState.Success -> { + val unknownStr = stringResource(R.string.unknown) + val movie by rememberUpdatedState(s.data) + val chosenStreams by rememberUpdatedState(state.chosenStreams) + LifecycleResumeEffect(destination.itemId) { + viewModel.maybePlayThemeSong(destination.itemId) + onPauseOrDispose { + viewModel.release() + } + } + MovieDetailsContent( + preferences = preferences, + movie = movie, + state = state, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + movie.id, + it.inWholeMilliseconds, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo(movie) + }, + moreOnClick = { + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = false, + item = movie, + chosenStreams = chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = state.canDelete, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + watchOnClick = { + viewModel.setWatched(movie.id, !movie.played) + }, + favoriteOnClick = { + viewModel.setFavorite(movie.id, !movie.favorite) + }, + onLongClickPerson = { index, person -> + showContextMenu = + ContextMenu.ForPerson( + fromLongClick = true, + person = person, + actions = + PersonContextActions( + navigateTo = viewModel::navigateTo, + onClickFavorite = viewModel::setFavorite, + ), + ) + }, + onLongClickSimilar = { _, similar -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = similar, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = false, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { index, extra -> + viewModel.navigateTo(extra.destination) + }, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, + canDelete = state.canDelete, + onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, + onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, + modifier = modifier, + ) + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) } overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } - chooseVersion?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { chooseVersion = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -359,6 +272,7 @@ fun MovieDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -376,13 +290,7 @@ private const val DISCOVER_ROW = SIMILAR_ROW + 1 fun MovieDetailsContent( preferences: UserPreferences, movie: BaseItem, - chosenStreams: ChosenStreams?, - people: List<Person>, - chapters: List<Chapter>, - trailers: List<Trailer>, - extras: List<ExtrasItem>, - similar: List<BaseItem>, - discovered: List<DiscoverItem>, + state: MovieState, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -395,6 +303,9 @@ fun MovieDetailsContent( onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + canDelete: Boolean, + onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -411,7 +322,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(bottom = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -425,15 +336,16 @@ fun MovieDetailsContent( MovieDetailsHeader( preferences = preferences, movie = movie, - chosenStreams = chosenStreams, + chosenStreams = state.chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, modifier = Modifier .fillMaxWidth() - .padding(top = 40.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandablePlayButtons( + title = movie.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -452,11 +364,21 @@ fun MovieDetailsContent( } } }, - trailers = trailers, + trailers = state.trailers, trailerOnClick = { position = TRAILER_ROW trailerOnClick.invoke(it) }, + canDelete = canDelete, + onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(state.chosenStreams, movie, onChooseVersion) { + ChooseVersionParams( + chosenStreams = state.chosenStreams, + mediaSources = movie.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() @@ -465,7 +387,7 @@ fun MovieDetailsContent( ) } } - if (people.isNotEmpty()) { + state.people.letNotEmpty { people -> item { PersonRow( people = people, @@ -484,11 +406,10 @@ fun MovieDetailsContent( ) } } - if (chapters.isNotEmpty()) { + state.chapters.letNotEmpty { chapters -> item { ChapterRow( chapters = chapters, - aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE, onClick = { position = CHAPTER_ROW playOnClick.invoke(it.position) @@ -501,7 +422,7 @@ fun MovieDetailsContent( ) } } - if (extras.isNotEmpty()) { + state.extras.letNotEmpty { extras -> item { ExtrasRow( extras = extras, @@ -517,7 +438,7 @@ fun MovieDetailsContent( ) } } - if (similar.isNotEmpty()) { + state.similar.letNotEmpty { similar -> item { val imageHeight = remember(movie.type) { @@ -556,13 +477,14 @@ fun MovieDetailsContent( ) } } - if (discovered.isNotEmpty()) { + state.discovered.letNotEmpty { discovered -> item { DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), + type = DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = DISCOVER_ROW 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 45e3ee54..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 @@ -13,8 +13,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -24,8 +22,10 @@ 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.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.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -50,16 +50,13 @@ fun MovieDetailsHeader( modifier = modifier, ) { // Title - Text( - text = movie.name ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TitleOrLogo( + item = movie, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, modifier = Modifier .fillMaxWidth(.75f) - .padding(start = 8.dp), + .padding(start = HeaderUtils.startPadding), ) Column( @@ -67,26 +64,31 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - movie.ui.quickDetails, + movie, movie.timeRemainingOrRuntime, - Modifier.padding(start = 8.dp), + Modifier.padding(start = HeaderUtils.startPadding), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp), + modifier = + Modifier.padding( + start = HeaderUtils.startPadding, + top = 4.dp, + bottom = 16.dp, + ), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } @@ -122,7 +124,7 @@ fun MovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 8.dp), + modifier = 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 ed46656c..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 @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.movie import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams @@ -14,44 +13,42 @@ 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.MediaReportService +import com.github.damontecres.wholphin.services.MediaManagementService 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.ui.SlimItemFields +import com.github.damontecres.wholphin.services.deleteItem +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 import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -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.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState 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.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.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = MovieViewModel.Factory::class) @@ -65,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, @@ -73,6 +70,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -80,118 +78,142 @@ class MovieViewModel fun create(itemId: UUID): MovieViewModel } - val loading = MutableLiveData<LoadingState>(LoadingState.Pending) - val item = MutableLiveData<BaseItem?>(null) - val trailers = MutableLiveData<List<Trailer>>(listOf()) - val people = MutableLiveData<List<Person>>(listOf()) - val chapters = MutableLiveData<List<Chapter>>(listOf()) - val extras = MutableLiveData<List<ExtrasItem>>(listOf()) - val similar = MutableLiveData<List<BaseItem>>() - val chosenStreams = MutableLiveData<ChosenStreams?>(null) - val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + private val _state = MutableStateFlow(MovieState()) + val state: StateFlow<MovieState> = _state init { init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete -> + _state.update { + it.copy(canDelete = 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@MovieViewModel.item.setValueOnMain(item) - item - } + private suspend fun getMovie(): BaseItem { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem(it) + } + return item + } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val item = fetchAndSetItem().await() - val result = + viewModelScope.launchDefault { + val movie = + try { + getMovie() + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch movie %s", itemId) + _state.update { it.copy(loading = DataLoadingState.Error(ex)) } + return@launchDefault + } + val chosenStreams = itemPlaybackRepository.getSelectedTracks( - item.id, - item, + itemId, + movie, userPreferencesService.getCurrent(), ) - val remoteTrailers = trailerService.getRemoteTrailers(item) - withContext(Dispatchers.Main) { - this@MovieViewModel.item.value = item - chosenStreams.value = result - this@MovieViewModel.trailers.value = remoteTrailers - loading.value = LoadingState.Success - backdropService.submit(item) + val remoteTrailers = trailerService.getRemoteTrailers(movie) + val chapters = Chapter.fromDto(movie.data) + _state.update { + it.copy( + loading = DataLoadingState.Success(movie), + chosenStreams = chosenStreams, + trailers = remoteTrailers, + chapters = chapters, + ) } + backdropService.submit(movie) viewModelScope.launchIO { - trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> - withContext(Dispatchers.Main) { - this@MovieViewModel.trailers.value = localTrailers + remoteTrailers + trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers -> + _state.update { + it.copy( + trailers = localTrailers + remoteTrailers, + ) } } } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@MovieViewModel.people.setValueOnMain(people) + val people = peopleFavorites.getPeopleFor(movie) + _state.update { + it.copy( + people = people, + ) + } } viewModelScope.launchIO { - val extras = extrasService.getExtras(item.id) - this@MovieViewModel.extras.setValueOnMain(extras) + val extras = extrasService.getExtras(itemId) + _state.update { + it.copy( + extras = extras, + ) + } } viewModelScope.launchIO { - val results = seerrService.similar(item).orEmpty() - discovered.update { results } + val results = seerrService.similar(movie).orEmpty() + _state.update { + it.copy( + discovered = results, + ) + } } - withContext(Dispatchers.Main) { - chapters.value = Chapter.fromDto(item.data, api) - } - if (!similar.isInitialized) { + if (state.value.similar.isEmpty()) { val similar = api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, - fields = SlimItemFields, + fields = ItemRowFields, limit = 25, ), ).content.items - .map { BaseItem.Companion.from(it, api) } - this@MovieViewModel.similar.setValueOnMain(similar) + .map { BaseItem(it) } + + _state.update { it.copy(similar = similar) } } } fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - fetchAndSetItem() + ) = viewModelScope.launchDefault { + try { + favoriteWatchManager.setWatched(itemId, played) + getMovie().let { movie -> + _state.update { + it.copy(loading = DataLoadingState.Success(movie)) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating watch status for movie %s", itemId) + showToast(context, "Something went wrong...") + } } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - val item = item.value - fetchAndSetItem() - if (item != null && itemId != item.id) { - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@MovieViewModel.people.setValueOnMain(people) + ) = viewModelScope.launchDefault { + try { + favoriteWatchManager.setFavorite(itemId, favorite) + val movie = getMovie() + _state.update { + it.copy(loading = DataLoadingState.Success(movie)) } + if (itemId != movie.id) { + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(movie) + _state.update { it.copy(people = people) } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating favorite %s", itemId) + showToast(context, "Something went wrong...") } } @@ -201,15 +223,12 @@ 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) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -221,7 +240,6 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -229,22 +247,14 @@ class MovieViewModel 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(itemId: UUID) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + themeSongPlayer.playThemeFor(itemId) addCloseable { themeSongPlayer.stop() } @@ -263,15 +273,36 @@ class MovieViewModel fun clearChosenStreams(chosenStreams: ChosenStreams?) { viewModelScope.launchIO { itemPlaybackRepository.deleteChosenStreams(chosenStreams) - item.value?.let { item -> + state.value.movie?.let { item -> val result = itemPlaybackRepository.getSelectedTracks( itemId, item, userPreferencesService.getCurrent(), ) - this@MovieViewModel.chosenStreams.setValueOnMain(result) + _state.update { it.copy(chosenStreams = result) } } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } + +data class MovieState( + val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending, + val trailers: List<Trailer> = emptyList(), + val people: List<Person> = emptyList(), + val chapters: List<Chapter> = emptyList(), + 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, +) { + val movie: BaseItem? = (loading as? DataLoadingState.Success<BaseItem>)?.data +} 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 new file mode 100644 index 00000000..8f873bcd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -0,0 +1,746 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.activity.compose.BackHandler +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.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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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 +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +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.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +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.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.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 +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +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.components.OverviewText +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.ifElse +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems +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 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.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.api.client.extensions.itemsApi +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.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class) +class AlbumViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val serverReportService: ServerReportService, + private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + 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( + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") initialSongId: UUID?, + ): AlbumViewModel + } + + private val _state = MutableStateFlow(AlbumState.EMPTY) + val state: StateFlow<AlbumState> = _state + + val currentMusic = musicService.state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val itemDeferred = + async { + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + } + 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() + + album.data.albumArtists + ?.map { it.name } + .orEmpty() + val isVariousArtists = + allArtists.firstOrNull { it?.lowercase() == "various artists" } != null || + album.data.artists + .orEmpty() + .size > 1 + _state.update { + it.copy( + album = album, + isVariousArtists = isVariousArtists, + imageUrl = imageUrl, + songs = songs, + initialSongIndex = initialSongIndex, + loading = LoadingState.Success, + ) + } + updateBackDrop() + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser?.id, + itemId = itemId, + excludeArtistIds = album.data.albumArtists?.map { it.id }, + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser?.id, + albumIds = 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) + } + } + } + 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)) } + } + } + } + + fun updateBackDrop() { + state.value.album?.let { album -> + viewModelScope.launchDefault { + getBackdropItemForAlbum(api, album)?.let { + backdropService.submit(it) + } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val album = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(album = album) } + } + + fun play( + shuffled: Boolean, + startIndex: Int = 0, + ) { + val songs = state.value.songs as ApiRequestPager<*> + play(songs, startIndex, shuffled) + } + } + +suspend fun getBackdropItemForAlbum( + api: ApiClient, + album: BaseItem, +): BaseItem? { + if (album.data.backdropImageTags?.isNotEmpty() == true) { + return album + } else { + val artistIds = + album.data.albumArtists + ?.shuffled() + ?.take(50) + ?.map { it.id } + return api.itemsApi + .getItems( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + ).content.items + .firstOrNull() + ?.let { BaseItem(it, false) } + } +} + +data class AlbumState( + val album: BaseItem?, + val isVariousArtists: Boolean, + val imageUrl: String?, + 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 { + val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending) + } +} + +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 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, initialSongId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsState() + val currentMusic by viewModel.currentMusic.collectAsState() + + 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.collectAsState() + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + val moreDialogActions = + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, _ -> viewModel.play(false, index) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + 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( + initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, + ) + + val songFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (position.row == SONG_ROW) { + songFocusRequester.tryRequestFocus() + } else { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + viewModel.updateBackDrop() + } + val backHandlerActive by remember { + derivedStateOf { + listState.firstVisibleItemIndex > itemsBefore + } + } + BackHandler(backHandlerActive) { + scope.launch { + listState.animateScrollToItem(itemsBefore) + firstBringIntoViewRequester.bringIntoView() + firstFocusRequester.tryRequestFocus() + } + } + Box(modifier = modifier) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 32.dp), + ) { + AlbumHeader( + album = album, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = album.title ?: "", + actions = + remember { + MusicButtonActions( + onClickPlay = { viewModel.play(it, 0) }, + onClickInstantMix = { viewModel.startInstantMix(album.id) }, + onClickFavorite = { + viewModel.setFavorite( + album.id, + !album.favorite, + ) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = album, + index = 0, + canDelete = + viewModel.canDelete( + album, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { + viewModel.deleteItem(album) + }, + ) + }, + favorite = album.favorite, + canDelete = state.canDelete, + buttonOnFocusChanged = { + if (it.isFocused) { + position = RowColumn(HEADER_ROW, 0) + scope.launch { bringIntoViewRequester.bringIntoView() } + } + }, + modifier = + Modifier + .onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }.focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + item { + Text( + text = stringResource(R.string.songs) + " (${state.songs.size})", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + } + itemsIndexed(state.songs) { index, song -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + SongListItem( + song = song, + onClick = { + position = RowColumn(SONG_ROW, index) + viewModel.play(false, index) + }, + onLongClick = { + if (song != null) { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + } + }, + onClickMore = { + if (song != null) { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + } + }, + showArtist = state.isVariousArtists, + isPlaying = song != null && currentMusic.currentItemId == song.id, + isQueued = song != null && song.id in currentMusic.queuedIds, + modifier = + Modifier + .fillMaxWidth(.75f) + .ifElse( + index == 0, + Modifier + .focusRequester(firstFocusRequester) + .bringIntoViewRequester(firstBringIntoViewRequester), + ).ifElse( + position.row == SONG_ROW && position.column == index, + Modifier.focusRequester(songFocusRequester), + ), + ) + } + } + if (state.musicVideos.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + position = RowColumn(MUSIC_VIDEO_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, + played = item?.played ?: false, + playPercent = item?.data?.userData?.playedPercentage ?: 0.0, + favorite = item?.favorite ?: false, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), + ) + } + } + 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( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + position = RowColumn(SIMILAR_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = item, + index = index, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + showGoTo = true, + ) + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + } + } + } + } + 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() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} + +@Composable +fun AlbumHeader( + album: BaseItem, + imageUrl: String?, + overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.padding(top = 32.dp), + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth(.20f) + .clip(RoundedCornerShape(16.dp)), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Artist + Text( + text = album.artistsString ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + Text( + text = album.name ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + QuickDetails( + album.ui.quickDetails, + null, + Modifier.padding(start = 8.dp), + ) + + album.data.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = 8.dp)) + } + + // Description + album.data.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + } + } + } +} 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 new file mode 100644 index 00000000..20a39e0f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -0,0 +1,843 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +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.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.RoundedCornerShape +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.rememberCoroutineScope +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.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +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.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +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.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.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.services.ServerReportService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.AspectRatios +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 +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.components.OverviewText +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.ifElse +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems +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 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.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.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.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class) +class ArtistViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + val serverRepository: ServerRepository, + val serverReportService: ServerReportService, + private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): ArtistViewModel + } + + private val _state = MutableStateFlow(ArtistState.EMPTY) + val state: StateFlow<ArtistState> = _state + + val currentMusic = musicService.state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val itemDeferred = + async { + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + } + val albumsDeferred = + async { + val request = + GetItemsRequest( + albumArtistIds = 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 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, + ) + } + + viewModelScope.launchIO { + val request = + GetItemsRequest( + artistIds = listOf(itemId), + fields = SlimItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + minCommunityRating = 1.0, + sortBy = + listOf( + ItemSortBy.COMMUNITY_RATING, + ), + sortOrder = listOf(SortOrder.DESCENDING), + limit = 10, + ) + val topSongs = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (topSongs.isNotEmpty()) { + _state.update { + it.copy(topSongs = topSongs) + } + } + } + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser?.id, + itemId = itemId, + excludeArtistIds = listOf(itemId), + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + updateMusicVideos() + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun refresh() { + viewModelScope.launchDefault { + state.value.artist?.let { + backdropService.submit(it) + } + } + } + + 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() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + // 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() + } + } + +data class ArtistState( + val artist: BaseItem?, + 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?>, + val canDelete: Boolean, +) { + companion object { + val EMPTY = + ArtistState( + null, + null, + emptyList(), + emptyList(), + emptyList(), + emptyList(), + LoadingState.Pending, + emptyList(), + false, + ) + } +} + +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 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 +fun ArtistDetailsPage( + preferences: UserPreferences, + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: ArtistViewModel = + hiltViewModel<ArtistViewModel, ArtistViewModel.Factory>( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + 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() } } + + 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 = { index, item -> viewModel.play(item) }, + onClickPlayNext = { _, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val artist = state.artist!! + val songFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (position.row == SONG_ROW) { + songFocusRequester.tryRequestFocus() + } else { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + viewModel.refresh() + } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + Box(modifier = modifier) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 16.dp), + ) { + ArtistHeader( + artist = artist, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = artist.title ?: "", + actions = + remember { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(artist, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(artist.id) }, + onClickFavorite = { + viewModel.setFavorite( + artist.id, + !artist.favorite, + ) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = artist, + index = 0, + canDelete = + viewModel.canDelete( + artist, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { viewModel.deleteItem(artist) }, + ) + }, + favorite = artist.favorite, + canDelete = state.canDelete, + buttonOnFocusChanged = { + if (it.isFocused) { + position = RowColumn(HEADER_ROW, 0) + scope.launch { bringIntoViewRequester.bringIntoView() } + } + }, + modifier = + Modifier + .onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }.focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (state.topSongs.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.popular_songs), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + } + itemsIndexed(state.topSongs) { index, song -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + SongListItem( + song = song, + onClick = { + position = RowColumn(SONG_ROW, index) + song?.let { viewModel.play(it) } + }, + onLongClick = { + if (song != null) { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + } + }, + onClickMore = { + if (song != null) { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = index, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + } + }, + showArtist = false, + isPlaying = song != null && currentMusic.currentItemId == song.id, + isQueued = song != null && song.id in currentMusic.queuedIds, + modifier = + Modifier + .fillMaxWidth(.75f) + .ifElse( + position.row == SONG_ROW && position.column == index, + Modifier.focusRequester(songFocusRequester), + ), + ) + } + } + } + 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, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + ) + }, + 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 { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + position = RowColumn(MUSIC_VIDEO_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, 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 = + 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( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, + played = item?.played ?: false, + playPercent = item?.data?.userData?.playedPercentage ?: 0.0, + favorite = item?.favorite ?: false, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), + ) + } + } + if (state.similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + position = RowColumn(SIMILAR_ROW, index) + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = true, + item = item, + index = index, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + showGoTo = true, + ) + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + } + } + } + } + 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() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} + +@Composable +fun ArtistHeader( + artist: BaseItem, + imageUrl: String?, + overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.padding(top = 32.dp), + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth(.20f) + .clip(RoundedCornerShape(16.dp)), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Artist + Text( + text = artist.artistsString ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + Text( + text = artist.name ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + QuickDetails( + artist.ui.quickDetails, + null, + Modifier.padding(start = 8.dp), + ) + + artist.data.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = 8.dp)) + } + + // Description + artist.data.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt new file mode 100644 index 00000000..d5ea2037 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.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.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme + +@Composable +fun BarVisualizer( + data: IntArray, + modifier: Modifier, +) { + var size by remember { mutableStateOf(IntSize.Zero) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.onSizeChanged { size = it }, + ) { + val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) } + val padding = 1.dp + val width = size.width / data.size + + data.forEachIndexed { index, data -> + val height by animateDpAsState( + targetValue = size.height * data / 256f, + animationSpec = tween(easing = LinearEasing), + ) + Box( + Modifier + .height(height) + .width(width) + .padding(start = if (index == 0) 0.dp else padding) + .background(MaterialTheme.colorScheme.border), + ) + } + } +} 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 new file mode 100644 index 00000000..e60e2a08 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +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.fillMaxSize +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.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +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.remember +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.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll +import org.jellyfin.sdk.model.api.LyricDto +import org.jellyfin.sdk.model.api.LyricLine + +@Composable +fun LyricsContent( + lyricsHaveFocus: Boolean, + lyrics: LyricDto?, + currentLyricPosition: Int?, + onClick: (LyricLine) -> Unit, + onFocusLyrics: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequesters = + remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } } + val listState = rememberLazyListState(currentLyricPosition ?: 0) + + val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f) + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + if (!lyricsHaveFocus) { + LaunchedEffect(currentLyricPosition) { + if (currentLyricPosition != null) { + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { + if (currentLyricPosition >= 0 && currentLyricPosition !in listState.firstVisibleItemIndex..it) { + listState.animateScrollToItem(currentLyricPosition) + } + } + bringIntoViewRequester.bringIntoView() + } + } + } + Column( + modifier + .nestedScroll(scrollConnection), + ) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .focusProperties { + onEnter = { + if (currentLyricPosition != null) { + currentLyricPosition.let { + focusRequesters + .getOrNull(currentLyricPosition) + ?.tryRequestFocus() + } + } else { + focusRequesters.getOrNull(0)?.tryRequestFocus() + } + } + }.onFocusChanged { + onFocusLyrics.invoke(it.hasFocus) + }, + ) { + if (lyrics?.lyrics?.isNotEmpty() == true) { + itemsIndexed(lyrics.lyrics) { index, lyric -> + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val color by animateColorAsState( + if (index == currentLyricPosition || currentLyricPosition == null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = .4f) + }, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + Surface( + onClick = { onClick.invoke(lyric) }, + interactionSource = interactionSource, + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f), + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + scale = + ClickableSurfaceDefaults.scale( + focusedScale = 1f, + pressedScale = .9f, + ), + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .onFocusChanged { + if (it.isFocused) onFocusLyrics.invoke(true) + }, + ) { + val text = + remember(lyric.text) { lyric.text.ifBlank { " " } } + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = if (focused) MaterialTheme.colorScheme.onSurface else color, + modifier = + Modifier + .padding(8.dp) + .ifElse( + index == currentLyricPosition, + Modifier.bringIntoViewRequester(bringIntoViewRequester), + ), + ) + } + } + } + } + } +} 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 new file mode 100644 index 00000000..96e82032 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +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.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.DeleteButton +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import kotlin.time.Duration + +@Composable +fun MusicExpandableButtons( + title: String, + actions: MusicButtonActions, + favorite: Boolean, + canDelete: Boolean, + buttonOnFocusChanged: (FocusState) -> Unit, + modifier: Modifier = Modifier, + showShuffle: Boolean = true, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item("play") { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { actions.onClickPlay.invoke(false) }, + modifier = + Modifier + .focusRequester(firstFocus) + .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( + title = R.string.instant_mix, + iconStringRes = R.string.fa_compass, + onClick = actions.onClickInstantMix, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + item("favorite") { + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = actions.onClickFavorite, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + if (canDelete) { + item("delete") { + DeleteButton( + title = title, + onConfirmDelete = actions.onConfirmDelete, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { actions.onClickMore.invoke() }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } +} + +data class MusicButtonActions( + val onClickPlay: (shuffle: Boolean) -> Unit, + val onClickInstantMix: () -> Unit, + val onClickFavorite: () -> Unit, + val onClickMore: () -> Unit, + val onConfirmDelete: () -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt new file mode 100644 index 00000000..1a519998 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.preferences.updateMusicPreferences + +fun getMusicPreferences() = + listOf( + AppSwitchPreference<AppPreferences>( + title = R.string.show_album_cover, + defaultValue = false, + getter = { it.musicPreferences.showAlbumArt }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showAlbumArt = value } + }, + ), + AppSwitchPreference<AppPreferences>( + title = R.string.show_visualizer, + defaultValue = false, + getter = { it.musicPreferences.showVisualizer }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showVisualizer = value } + }, + ), + AppSwitchPreference<AppPreferences>( + title = R.string.show_backdrop, + defaultValue = false, + getter = { it.musicPreferences.showBackdrop }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showBackdrop = value } + }, + ), + AppSwitchPreference<AppPreferences>( + title = R.string.show_lyrics, + defaultValue = false, + getter = { it.musicPreferences.showLyrics }, + setter = { prefs, value -> + prefs.updateMusicPreferences { showLyrics = value } + }, + ), + ) 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 new file mode 100644 index 00000000..87062cf2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt @@ -0,0 +1,157 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +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.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.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList +import kotlinx.coroutines.delay +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import java.util.UUID + +abstract class MusicViewModel( + internal val itemId: UUID, + internal val context: Context, + internal val api: ApiClient, + internal val musicService: MusicService, + internal val navigationManager: NavigationManager, + internal val mediaManagementService: MediaManagementService, +) : ViewModel() { + fun play( + pager: ApiRequestPager<*>, + startIndex: Int = 0, + shuffled: Boolean = false, + ) { + viewModelScope.launchIO { + musicService.setQueue(pager, startIndex, shuffled) + } + } + + fun play( + item: BaseItem, + startIndex: Int = 0, + shuffled: Boolean = false, + ) { + viewModelScope.launchIO { + Timber.v("Playing %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.setQueue(listOf(item), shuffled) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + BaseItemKind.PLAYLIST -> { + val pager = getPagerForPlaylist(api, item.id) + musicService.setQueue(pager, startIndex, shuffled) + } + + else -> { + Timber.w("Unknown item type to play for music: %s", item.type) + } + } + } + } + + fun playNext(song: BaseItem) { + viewModelScope.launchDefault { + musicService.playNext(song) + } + } + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + + fun startInstantMix(itemId: UUID) { + viewModelScope.launchIO { + Timber.v("Starting instant mix for %s", itemId) + musicService.startInstantMix(itemId) + } + viewModelScope.launchDefault { + // TODO better way to wait for query above to start + delay(250) + navigationManager.navigateTo(Destination.NowPlaying) + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + if (item.id == itemId) { + navigationManager.goBack() + } else { + init() + } + } + } + + internal abstract fun init() +} + +fun ViewModel.addToQueue( + api: ApiClient, + musicService: MusicService, + item: BaseItem, + index: Int, +) { + viewModelScope.launchIO { + Timber.v("addToQueue %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.addAllToQueue(BlockingList.of(listOf(item)), 0) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + BaseItemKind.PLAYLIST -> { + val pager = getPagerForPlaylist(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + else -> { + Timber.w("Unknown item type to queue for music: %s", item.type) + } + } + } +} + +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/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt new file mode 100644 index 00000000..cf9f9c7c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt @@ -0,0 +1,133 @@ +package com.github.damontecres.wholphin.ui.detail.music + +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.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +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.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +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.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun MusicViewOptionsDialog( + appPreferences: AppPreferences, + onDismissRequest: () -> Unit, + onViewOptionsChange: (AppPreferences) -> Unit, + onEnableVisualizer: () -> Unit, +) { + val context = LocalContext.current + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + val items = remember { getMusicPreferences() } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + + Column { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + ) + } + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + 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), + ), + ) { + items(items) { pref -> + pref as AppPreference<AppPreferences, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(appPreferences) + // Using title is a bit hacky + when (pref.title) { + R.string.show_visualizer -> { + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + newValue as Boolean + if (newValue) { + onEnableVisualizer.invoke() + } else { + onViewOptionsChange.invoke( + pref.setter( + appPreferences, + newValue, + ), + ) + } + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + }, + ) + } + + else -> { + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(appPreferences, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + }, + ) + } + } + } + } + } +} 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 new file mode 100644 index 00000000..f8dc0ccd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt @@ -0,0 +1,315 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +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.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +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.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberNextButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPreviousButtonState +import androidx.media3.ui.compose.state.rememberRepeatButtonState +import androidx.media3.ui.compose.state.rememberShuffleButtonState +import androidx.tv.material3.ClickableSurfaceDefaults +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.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButtons +import com.github.damontecres.wholphin.ui.playback.overlay.buttonSpacing +import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlin.time.Duration.Companion.seconds + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingButtons( + player: Player, + controllerViewState: ControllerViewState, + initialFocusRequester: FocusRequester, + onClickMore: () -> Unit, + onClickStop: () -> Unit, + modifier: Modifier = Modifier, +) { + val playPauseState = rememberPlayPauseButtonState(player) + val previousState = rememberPreviousButtonState(player) + val nextState = rememberNextButtonState(player) + val shuffleState = rememberShuffleButtonState(player) + val repeatState = rememberRepeatButtonState(player) + + val onControllerInteraction = remember { { controllerViewState.pulseControls() } } + Box( + modifier = modifier.focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + modifier = Modifier.align(Alignment.CenterStart), + ) { + PlaybackButton( + iconRes = R.drawable.vector_settings, + onClick = { + onControllerInteraction.invoke() + onClickMore.invoke() + }, + enabled = true, + onControllerInteraction = onControllerInteraction, + modifier = Modifier, + ) + PlaybackButton( + iconRes = R.drawable.baseline_stop_24, + onClick = { + onClickStop.invoke() + }, + enabled = true, + onControllerInteraction = onControllerInteraction, + modifier = Modifier, + ) + } + PlaybackButtons( + player = player, + initialFocusRequester = initialFocusRequester, + onControllerInteraction = onControllerInteraction, + onPlaybackActionClick = { + when (it) { + PlaybackAction.Next -> { + nextState.onClick() + } + + PlaybackAction.Previous -> { + previousState.onClick() + } + + is PlaybackAction.ToggleCaptions -> { + TODO() + } + + else -> {} + } + }, + showPlay = playPauseState.showPlay, + previousEnabled = previousState.isEnabled, + nextEnabled = nextState.isEnabled, + seekBack = 10.seconds, + skipBackOnResume = null, + seekForward = 30.seconds, // TODO + modifier = Modifier.align(Alignment.Center), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + ShuffleButton( + active = shuffleState.shuffleOn, + enabled = shuffleState.isEnabled, + onClick = { + shuffleState.onClick() + }, + onControllerInteraction = onControllerInteraction, + ) + RepeatButton( + repeatMode = repeatState.repeatModeState, + enabled = repeatState.isEnabled, + onClick = { + repeatState.onClick() + }, + onControllerInteraction = onControllerInteraction, + ) + } + } +} + +@Composable +fun ShuffleButton( + active: Boolean, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val selectedColor = MaterialTheme.colorScheme.border + val focused by interactionSource.collectIsFocusedAsState() + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Text( + text = stringResource(R.string.fa_shuffle), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + when { + focused && active -> MaterialTheme.colorScheme.onSurface + focused && !active -> MaterialTheme.colorScheme.surface + !focused && active -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + } +} + +@Composable +fun RepeatButton( + repeatMode: Int, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val selectedColor = MaterialTheme.colorScheme.border + val focused by interactionSource.collectIsFocusedAsState() + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Box( + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) { + Text( + text = stringResource(R.string.fa_repeat), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + if (focused) { + when (repeatMode) { + Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface + Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.surface + } + } else { + when (repeatMode) { + Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface + Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) + } + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) + if (repeatMode == Player.REPEAT_MODE_ONE) { + Text( + text = "1", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .offset(x = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(1.dp), + ).align(Alignment.BottomStart), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +fun ShuffleButtonPreview() { + val source = remember { PreviewInteractionSource() } + WholphinTheme { + Column { + Row { + RepeatButton( + repeatMode = Player.REPEAT_MODE_OFF, + onClick = {}, + onControllerInteraction = {}, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ALL, + onClick = {}, + onControllerInteraction = {}, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ONE, + onClick = {}, + onControllerInteraction = {}, + ) + } + Row { + RepeatButton( + repeatMode = Player.REPEAT_MODE_OFF, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ALL, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + RepeatButton( + repeatMode = Player.REPEAT_MODE_ONE, + onClick = {}, + onControllerInteraction = {}, + interactionSource = source, + ) + } + } + } +} 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 new file mode 100644 index 00000000..8d12bb33 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -0,0 +1,259 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.Row +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.layout.size +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 +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.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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +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.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 +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar +import com.github.damontecres.wholphin.ui.preferences.MoveButton +import com.github.damontecres.wholphin.ui.roundSeconds +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingOverlay( + state: NowPlayingState, + player: Player, + current: AudioItem?, + controllerViewState: ControllerViewState, + onClickSong: (Int, AudioItem) -> Unit, + onLongClickSong: (Int, AudioItem) -> Unit, + onClickMore: () -> Unit, + onMoveQueue: (Int, MoveDirection) -> Unit, + onClickMoreItem: (Int, AudioItem) -> Unit, + onClickStop: () -> Unit, + lyricsFocusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + val currentItemFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + var queueHasFocus by remember { mutableStateOf(false) } + val height by animateFloatAsState( + if (queueHasFocus) { + 1f + } else { + .33f + }, + animationSpec = tween(durationMillis = 500), + ) + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = state.musicServiceState.currentIndex, + ) + var showButtons by remember { mutableStateOf(true) } + + val firstFocusRequester = remember { FocusRequester() } + BackHandler(!showButtons) { + scope.launch { + listState.scrollToItem(0) + firstFocusRequester.tryRequestFocus() + } + } + + Column( + modifier = + modifier + .padding(16.dp) + .fillMaxHeight(height), + ) { + SeekBar( + player = player, + controllerViewState = controllerViewState, + onSeekProgress = {}, + interactionSource = remember { MutableInteractionSource() }, + isEnabled = false, + intervals = 0, + seekBack = Duration.ZERO, + seekForward = Duration.ZERO, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp) + .fillMaxWidth(.95f), + ) + AnimatedVisibility( + visible = showButtons, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = + Modifier + .align(Alignment.CenterHorizontally), + ) { + NowPlayingButtons( + player = player, + controllerViewState = controllerViewState, + initialFocusRequester = focusRequester, + onClickMore = onClickMore, + onClickStop = onClickStop, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + ) + } + val queue = + rememberQueue( + player, + state.musicServiceState.queueVersion, + state.musicServiceState.queueSize, + ) + if (queue.isEmpty()) { + Text("No items") + } else { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp), + ) + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + modifier = + Modifier + .fillMaxSize() + .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 = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = .5f), + shape = RoundedCornerShape(8.dp), + ).padding(end = 8.dp) + .onFocusChanged { + if (it.hasFocus) showButtons = index < 3 + controllerViewState.pulseControls() + }.animateItem() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + SongListItem( + title = song.title, + artist = song.artistNames, + indexNumber = index + 1, + runtime = song.runtime?.roundSeconds, + showArtist = true, + isPlaying = current?.id == song.id, + onClick = { onClickSong.invoke(index, song) }, + onLongClick = { onLongClickSong.invoke(index, song) }, + modifier = + Modifier + .weight(1f) + .ifElse( + index == 0, + Modifier.focusRequester(firstFocusRequester), + ).ifElse( + index == state.musicServiceState.currentIndex, + Modifier.focusRequester(currentItemFocusRequester), + ), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton( + icon = R.string.fa_caret_up, + enabled = index > 0, + 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) + scope.launch { + bringIntoViewRequester.bringIntoView() + } + }, + ) + Button( + onClick = { onClickMoreItem.invoke(index, song) }, + enabled = true, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } + } + } + } + } +} 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 new file mode 100644 index 00000000..5b22e6d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -0,0 +1,489 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.Manifest +import android.view.Gravity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.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 +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R +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.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.QueueContextActions +import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.nav.Backdrop +import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler +import com.github.damontecres.wholphin.ui.playback.isUp +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) +@Composable +fun NowPlayingPage( + modifier: Modifier = Modifier, + viewModel: NowPlayingViewModel = + hiltViewModel<NowPlayingViewModel, NowPlayingViewModel.Factory>( + creationCallback = { it.create() }, + ), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + val player = viewModel.player + val currentMediaItem = rememberCurrentMediaItemState(player) + val current = (currentMediaItem.mediaItem?.localConfiguration?.tag as? AudioItem) + val viz by viewModel.viz.collectAsState() + + val controllerViewState = viewModel.controllerViewState + val preferences = + viewModel.userPreferencesService.flow + .collectAsState( + UserPreferences( + AppPreferences.getDefaultInstance(), + null, + ), + ).value.appPreferences + val musicPrefs = preferences.musicPreferences + + val keyHandler = + remember(preferences) { + PlaybackKeyHandler( + player = player, + controlsEnabled = true, + skipWithLeftRight = false, + seekForward = 30.seconds, + seekBack = 10.seconds, +// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds, +// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = {}, + clearSkipIndicator = {}, + skipBackOnResume = null, +// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.playbackPreferences.oneClickPause, + onStop = { + viewModel.stop() + }, + 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 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(lyricsFocused) { + if (lyricsFocused != null) { + controllerViewState.hideControls() + delay(5.seconds) + if (!controllerViewState.controlsVisible) { + focusRequester.tryRequestFocus() + } + lyricsFocused = null + } + } + BackHandler(lyricsFocused != null) { + focusRequester.tryRequestFocus() + } + + var showRationaleDialog by remember { mutableStateOf(false) } + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + viewModel.startVisualizer(isGranted, true) + } + + Box(modifier) { + Backdrop( + backdrop = state.backdropResult, + drawerIsOpen = false, + backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR, + modifier = Modifier.fillMaxSize(), + enableTopScrim = false, + useExistingImageAsPlaceholder = true, + crossfadeDuration = 1.5.seconds, + ) + Box( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { key -> + if (!controllerViewState.controlsVisible && + key.type == KeyEventType.KeyUp && + isUp(key) && + hasLyrics + ) { + lyricsFocusRequester.tryRequestFocus() + true + } else { + keyHandler.onKeyEvent(key) + } + }.focusRequester(focusRequester) + .focusable(), + ) + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.wrapContentWidth(), + ) { + val enter = + remember { + expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + } + val exit = + remember { + shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + } + //noinspection UnnecessaryFullyQualifiedName + androidx.compose.animation.AnimatedVisibility( + visible = musicPrefs.showAlbumArt, + enter = enter, + exit = exit, + modifier = + Modifier + .padding(32.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(.5f), + ) { + AsyncImage( + contentDescription = null, + model = current?.imageUrl, + modifier = + Modifier + .size(240.dp) + .clip(RoundedCornerShape(16.dp)), + ) + current?.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + ) + } + current?.albumTitle?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + ) + } + current?.artistNames?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + //noinspection UnnecessaryFullyQualifiedName + androidx.compose.animation.AnimatedVisibility( + visible = musicPrefs.showVisualizer, + enter = enter, + exit = exit, + modifier = + Modifier + .padding(horizontal = 32.dp) + .align(Alignment.CenterStart), + ) { + val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f) + BarVisualizer( + data = viz, + modifier = + Modifier + .fillMaxHeight(.75f) + .fillMaxWidth(visualizerWidth) + .align(Alignment.CenterStart), + ) + } + } + + AnimatedVisibility( + visible = musicPrefs.showLyrics && state.hasLyrics, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + modifier = + Modifier + .focusRequester(lyricsFocusRequester), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(horizontal = 32.dp, vertical = 100.dp) + .fillMaxHeight(), + ) { + LyricsContent( + lyrics = state.lyrics, + currentLyricPosition = state.currentLyricIndex, + lyricsHaveFocus = lyricsFocused != null, + onFocusLyrics = { + lyricsFocused = Date() + }, + onClick = { + it.start + ?.ticks + ?.inWholeMilliseconds + ?.let { player.seekTo(it) } + }, + modifier = + Modifier + .fillMaxSize(), +// .width(360.dp), + ) + } + } + } + val showContextForItem = + remember { + { fromLongClick: Boolean, index: Int, song: AudioItem -> + showContextMenu = + ContextMenu.ForQueue( + fromLongClick = fromLongClick, + item = song, + index = index, + actions = + QueueContextActions( + onNavigate = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { index, _ -> viewModel.play(index) }, + onClickPlayNext = { index, _ -> viewModel.playNext(index) }, + onClickRemoveFromQueue = { index, _ -> + viewModel.removeFromQueue( + index, + ) + }, + ), + ) + } + } + + BackHandler(controllerViewState.controlsVisible) { + controllerViewState.hideControls() + } + AnimatedVisibility( + visible = controllerViewState.controlsVisible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + NowPlayingOverlay( + state = state, + player = player, + current = current, + controllerViewState = controllerViewState, + onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, + onClickMore = { showViewOptionsDialog = true }, + onClickSong = { index, _ -> viewModel.play(index) }, + onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) }, + onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) }, + onClickStop = { viewModel.stop() }, + lyricsFocusRequester = lyricsFocusRequester, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .drawBehind { + drawRect( + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = 0f, + endY = size.height, + ), + ) + }, + ) + } + if (state.musicServiceState.loadingState is LoadingState.Loading) { + LoadingPage(focusEnabled = false) + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + if (showViewOptionsDialog) { + MusicViewOptionsDialog( + appPreferences = preferences, + onDismissRequest = { showViewOptionsDialog = false }, + onViewOptionsChange = { viewModel.updatePreferences(it) }, + onEnableVisualizer = { + val showRationale = + context + .findActivity() + ?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true + when { + !state.visualizerPermissions && showRationale -> { + showRationaleDialog = true + } + + !state.visualizerPermissions -> { + launcher.launch(Manifest.permission.RECORD_AUDIO) + } + + else -> { + viewModel.startVisualizer(true, true) + } + } + }, + ) + } + if (showRationaleDialog) { + RecordAudioRationaleDialog( + onDismissRequest = { showRationaleDialog = false }, + onClick = { + showRationaleDialog = false + launcher.launch(Manifest.permission.RECORD_AUDIO) + }, + ) + } +} + +@Composable +fun NowPlayingBottomDialog( + showDebugInfo: Boolean, + lyricsActive: Boolean, + songHasLyrics: Boolean, + onDismissRequest: () -> Unit, + onClickShowDebug: () -> Unit, + onClickLyrics: () -> Unit, +) { + val choices = + mapOf( + BottomDialogItem( + data = 0, + headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ) to onClickShowDebug, + BottomDialogItem( + data = 0, + headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics), + supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null, + ) to onClickLyrics, + ) + + BottomDialog( + choices = choices.keys.toList(), + onDismissRequest = { + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + choices[choice]?.invoke() + onDismissRequest.invoke() + }, + gravity = Gravity.START, + ) +} + +@Composable +private fun RecordAudioRationaleDialog( + onDismissRequest: () -> Unit, + onClick: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.visualizer_rationale), + color = MaterialTheme.colorScheme.onSurface, + ) + Button( + onClick = onClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.continue_string), + ) + } + } + } +} 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 new file mode 100644 index 00000000..cfaa1cfc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -0,0 +1,19 @@ +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 + +@Stable +data class NowPlayingState( + val musicServiceState: MusicServiceState, + val currentMediaItem: AudioItem? = null, + val lyrics: LyricDto? = null, + val currentLyricIndex: Int? = null, + val visualizerPermissions: Boolean = false, + val backdropResult: BackdropResult = BackdropResult.NONE, +) { + val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty() +} 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 new file mode 100644 index 00000000..af14ff70 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -0,0 +1,393 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.audiofx.Visualizer +import androidx.core.content.ContextCompat +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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.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.lyricsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.ImageType +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.minutes + +@UnstableApi +@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) +class NowPlayingViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val musicService: MusicService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + private val preferencesDataStore: DataStore<AppPreferences>, + val navigationManager: NavigationManager, + val userPreferencesService: UserPreferencesService, + ) : ViewModel(), + Visualizer.OnDataCaptureListener, + Player.Listener { + @AssistedFactory + interface Factory { + fun create(): NowPlayingViewModel + } + + private val visualizerMutex = Mutex() + private var visualizer: Visualizer? = null + + val controllerViewState = + ControllerViewState( + AppPreference.ControllerTimeout.defaultValue, + true, + ) + + val state = MutableStateFlow(NowPlayingState(musicService.state.value)) + val player get() = musicService.player + + val viz = MutableStateFlow<IntArray>(IntArray(0)) + + private val lyricCache = + InMemoryKache<UUID, LyricDto>(20) { + creationScope = CoroutineScope(WholphinDispatchers.IO) + } + + init { + state.update { + it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem) + } + player.addListener(this) + addCloseable { + player.removeListener(this) + visualizer?.release() + } + val visualizerPermissions = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + startVisualizer(visualizerPermissions, false) + viewModelScope.launchDefault { + musicService.state.collectLatest { musicServiceState -> + if (musicServiceState.status != NowPlayingStatus.IDLE) { + visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING + } + + state.update { it.copy(musicServiceState = musicServiceState) } + } + } + viewModelScope.launchDefault { + viewModelScope + .launchDefault { + controllerViewState.observe() + }.join() + controllerViewState.pulseControls() + } + viewModelScope.launchDefault { backdropService.clearBackdrop() } + listenForBackdrop() + listenForLyrics() + } + + fun reportInteraction() { + controllerViewState.pulseControls() + } + + private fun listenForLyrics() { + viewModelScope.launchDefault { + 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 = + lyrics.lyrics + .indexOfLast { + it.start?.ticks?.let { lyricPosition >= it } == true + }.takeIf { it >= 0 } + state.update { + it.copy( + lyrics = lyrics, + currentLyricIndex = lyricIndex, + ) + } + delay(150.milliseconds) + } + } + } + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + val audio = mediaItem?.localConfiguration?.tag as? AudioItem + Timber.v("onMediaItemTransition to %s", audio?.id) + state.update { + it.copy( + currentMediaItem = audio, + lyrics = null, + currentLyricIndex = null, + ) + } + } + + private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? = + audio?.let { audio -> + if (audio.hasLyrics) { + lyricCache.getOrPut(audio.id) { + // TODO remote lyrics? + try { + api.lyricsApi.getLyrics(audio.id).content + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching lyrics for %s", audio.id) + null + } + } + } else { + null + } + } + + 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 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 = itemId.toString(), + imageUrl = imageUrl, + primaryColor = primaryColor, + secondaryColor = secondaryColor, + tertiaryColor = tertiaryColor, + ) + state.update { it.copy(backdropResult = backdropResult) } + } + + private fun clearBackdrop() { + state.update { it.copy(backdropResult = BackdropResult.NONE) } + } + + fun moveQueue( + index: Int, + direction: MoveDirection, + ) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) } + + fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) } + + fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) } + + fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) } + + fun stop() { + viewModelScope.launchDefault { + musicService.stop() + navigationManager.goBack() + } + } + + override fun onFftDataCapture( + visualizer: Visualizer, + fft: ByteArray, + samplingRate: Int, + ) { + } + + override fun onWaveFormDataCapture( + visualizer: Visualizer, + waveform: ByteArray, + samplingRate: Int, + ) { + val resolution = 96 + val captureSize = + Visualizer.getCaptureSizeRange()[1] + val groupSize = (captureSize / resolution.toFloat()).toInt() + val processed = + waveform + .toList() + .chunked(groupSize) + .map { it.average().toInt() + 128 } + .toIntArray() + viz.update { processed } + } + + fun updatePreferences(prefs: AppPreferences) { + viewModelScope.launchDefault { + preferencesDataStore.updateData { + prefs + } + } + } + + private fun initVisualizer() { + viewModelScope.launchDefault { + visualizerMutex.withLock { + val prefs = preferencesDataStore.data.first() + if (visualizer == null && + state.value.visualizerPermissions && + prefs.musicPreferences.showVisualizer + ) { + Timber.v("Creating visualizer") + visualizer = + Visualizer(onMain { player.audioSessionId }).apply { + captureSize = Visualizer.getCaptureSizeRange()[1] + setDataCaptureListener( + this@NowPlayingViewModel, + Visualizer.getMaxCaptureRate() / 3, + true, + false, + ) + enabled = true + } + } + } + } + } + + fun startVisualizer( + permissionGranted: Boolean, + updatePreferences: Boolean, + ) { + Timber.v("startVisualizer: permissionGranted=%s", permissionGranted) + state.update { + it.copy( + visualizerPermissions = permissionGranted, + ) + } + viewModelScope.launchDefault { + if (updatePreferences || !permissionGranted) { + preferencesDataStore.updateData { + it.updateMusicPreferences { showVisualizer = permissionGranted } + } + } + initVisualizer() + } + } + } 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 new file mode 100644 index 00000000..e4664ac9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -0,0 +1,245 @@ +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 +import androidx.compose.foundation.layout.Row +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.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +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.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.ListItemDefaults +import androidx.tv.material3.LocalContentColor +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.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 +import org.jellyfin.sdk.model.extensions.ticks +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@Composable +fun SongListItem( + song: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + onClickMore: () -> Unit, + modifier: Modifier = Modifier, + showArtist: Boolean = false, + isPlaying: Boolean = false, + isQueued: Boolean = false, +) = SongListItem( + title = song?.title, + artist = + if (showArtist) { + remember(song) { song?.data?.artists?.joinToString(", ") } + } else { + null + }, + indexNumber = song?.data?.indexNumber, + runtime = + remember(song) { + song + ?.data + ?.runTimeTicks + ?.ticks + ?.roundSeconds + }, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + showArtist = showArtist, + isPlaying = isPlaying, + isQueued = isQueued, + showMoreButton = true, + onClickMore = onClickMore, +) + +@Composable +fun SongListItem( + title: String?, + artist: String?, + indexNumber: Int?, + runtime: Duration?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + showArtist: Boolean = false, + isPlaying: Boolean = false, + isQueued: Boolean = false, + showMoreButton: Boolean = false, + onClickMore: () -> Unit = {}, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + val focused by interactionSource.collectIsFocusedAsState() + val leadingContent: @Composable (BoxScope.() -> Unit) = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = indexNumber?.toString() ?: "", + ) + MusicQueueMarker( + isPlaying = isPlaying, + isQueued = isQueued, + ) + } + } + val headlineContent = @Composable { + Text( + text = title ?: "", + maxLines = 1, + modifier = Modifier.enableMarquee(focused), + ) + } + val trailingContent = @Composable { + Text( + text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "", + ) + } + + 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), + ) + if (showMoreButton) { + Button( + onClick = onClickMore, + enabled = true, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } +} + +val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") } + +/** + * Add an indicator for if the item is currently playing or queued + */ +@Composable +fun MusicQueueMarker( + isPlaying: Boolean, + isQueued: Boolean, + modifier: Modifier = Modifier, +) { + if (isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = modifier, + ) + } else if (isQueued) { + Box( + modifier = + modifier + .padding(horizontal = 8.dp) + .clip(CircleShape) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} + +@PreviewTvSpec +@Composable +fun SongListItemPreview() { + WholphinTheme { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 1, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = false, + ) + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 1, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + showArtist = false, + isQueued = true, + showMoreButton = true, + ) + SongListItem( + title = "Song title", + artist = "Artists", + indexNumber = 2, + runtime = 2.minutes + 30.seconds, + onClick = {}, + onLongClick = { }, + modifier = Modifier, + 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 new file mode 100644 index 00000000..26346e7c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import java.util.UUID + +suspend fun ViewModel.getPagerForAlbum( + api: ApiClient, + albumId: UUID, +): ApiRequestPager<GetItemsRequest> { + val request = + GetItemsRequest( + parentId = albumId, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DetailItemFields, + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} + +suspend fun ViewModel.getPagerForArtist( + api: ApiClient, + artistId: UUID, +): ApiRequestPager<GetItemsRequest> { + val request = + GetItemsRequest( + artistIds = listOf(artistId), + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DetailItemFields, + // TODO better sort + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} + +suspend fun ViewModel.getPagerForPlaylist( + api: ApiClient, + playlistId: UUID, +): ApiRequestPager<GetItemsRequest> { + val request = + GetItemsRequest( + parentId = playlistId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DetailItemFields, + sortBy = + listOf( + ItemSortBy.DEFAULT, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} 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 9b6d81df..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 @@ -25,6 +27,9 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + canDelete: Boolean, + onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -37,6 +42,7 @@ fun FocusedEpisodeFooter( modifier = modifier, ) { ExpandablePlayButtons( + title = ep.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -47,6 +53,16 @@ fun FocusedEpisodeFooter( buttonOnFocusChanged = buttonOnFocusChanged, trailers = null, 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 ff0ddade..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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusState @@ -12,6 +13,7 @@ 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.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.VideoStreamDetails @@ -31,17 +33,21 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier) + EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) - ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime) + ep?.let { + QuickDetails( + it, + it.timeRemainingOrRuntime, + Modifier.padding(start = HeaderUtils.startPadding), + ) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier, + modifier = Modifier.padding(start = HeaderUtils.startPadding), ) } OverviewText( 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 55335057..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,9 @@ 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 import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,11 +17,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete 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 @@ -31,20 +34,18 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color 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.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.ExtrasItem 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.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards @@ -54,37 +55,40 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ConfirmDialog +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.DeleteButton 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.ExpandablePlayButton 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.Optional import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.PersonContextActions import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.TrailerButton 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson 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 @@ -102,154 +106,185 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - 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 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) } + var showWatchConfirmation by remember { mutableStateOf(false) } - var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage() - } - - LoadingState.Success -> { - item?.let { item -> - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + if (itemId == destination.itemId) { + // Confirm if marking whole series + showWatchConfirmation = true + } else { + viewModel.setWatched(itemId, watched, null) } - } + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, null) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog = Optional.present(itemId) + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { item, source -> + viewModel.savePlayVersion( + item, + source.id!!.toUUID(), + ) + }, + onChooseTracks = { result -> + viewModel.saveTrackSelection( + result.item, + result.itemPlayback, + result.trackIndex, + result.streamType, + ) + }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onClearChosenStreams = {}, + ) + } - 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, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { + LifecycleResumeEffect(destination.itemId) { + viewModel.refresh() + + onPauseOrDispose { + viewModel.release() + } + } + + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) + } + + is DataLoadingState.Success<BaseItem> -> { + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() + + 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 -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + onConfirm = { + viewModel.setWatchedSeries(!played) + showWatchConfirmation = false }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = item.name ?: context.getString(R.string.unknown), - overview = item.data.overview, - genres = item.data.genres.orEmpty(), - files = listOf(), - ) - }, - 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) - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - moreActions = - MoreDialogActions( - navigateTo = { viewModel.navigateTo(it) }, - onClickWatch = { itemId, played -> - viewModel.setWatched(itemId, played, null) - }, - onClickFavorite = { itemId, played -> - viewModel.setFavorite(itemId, played, null) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ), ) - 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 - }, - ) - } } } } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } overviewDialog?.let { info -> ItemDetailsDialog( info = info, @@ -257,15 +292,6 @@ fun SeriesDetails( onDismissRequest = { overviewDialog = null }, ) } - seasonDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - waitToLoad = params.fromLongClick, - onDismissRequest = { seasonDialog = null }, - ) - } showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -280,6 +306,7 @@ fun SeriesDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -305,6 +332,7 @@ fun SeriesDetailsContent( discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -314,8 +342,11 @@ fun SeriesDetailsContent( favoriteOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, - moreActions: MoreDialogActions, + onShowContextMenu: (ContextMenu) -> Unit, + actions: ContextMenuActions, onClickDiscover: (Int, DiscoverItem) -> Unit, + discoverSeries: DiscoverItem?, + onClickDiscoverSeries: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -326,7 +357,6 @@ fun SeriesDetailsContent( val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } RequestOrRestoreFocus(focusRequesters.getOrNull(position)) - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } Box( modifier = modifier, @@ -334,7 +364,6 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( @@ -345,18 +374,20 @@ fun SeriesDetailsContent( item { SeriesDetailsHeader( series = series, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier .fillMaxWidth() .bringIntoViewRequester(bringIntoViewRequester) - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 8.dp) + .padding(start = HeaderUtils.startPadding) .focusRequester(focusRequesters[HEADER_ROW]) .focusRestorer(playFocusRequester) .focusGroup() @@ -436,6 +467,43 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + title = series.title ?: "", + onConfirmDelete = { + position = HEADER_ROW + actions.onDeleteItem.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + AnimatedVisibility( + visible = discoverSeries != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExpandableFaButton( + title = R.string.discover, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = onClickDiscoverSeries, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -477,18 +545,17 @@ fun SeriesDetailsContent( }, onLongClick = { index, person -> position = PEOPLE_ROW - val items = - buildMoreDialogItemsForPerson( - context = context, - person = person, - actions = moreActions, - ) - moreDialog = - DialogParams( + onShowContextMenu.invoke( + ContextMenu.ForPerson( fromLongClick = true, - title = person.name ?: "", - items = items, - ) + person = person, + actions = + PersonContextActions( + navigateTo = actions.navigateTo, + onClickFavorite = actions.onClickFavorite, + ), + ), + ) }, modifier = Modifier @@ -524,22 +591,19 @@ fun SeriesDetailsContent( }, onLongClickItem = { index, item -> position = SIMILAR_ROW - val items = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = null, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - actions = moreActions, - ) - moreDialog = - DialogParams( + onShowContextMenu.invoke( + ContextMenu.ForBaseItem( fromLongClick = true, - title = item.name ?: "", - items = items, - ) + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = false, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = actions, + ), + ) }, cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( @@ -564,8 +628,9 @@ fun SeriesDetailsContent( DiscoverRow( row = DiscoverRowData( - stringResource(R.string.discover), + ResStringProvider(R.string.discover), DataLoadingState.Success(discovered), + type = DiscoverRequestType.UNKNOWN, ), onClickItem = { index: Int, item: DiscoverItem -> position = DISCOVER_ROW @@ -580,22 +645,14 @@ fun SeriesDetailsContent( } } } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } } @Composable fun SeriesDetailsHeader( series: BaseItem, + showLogo: Boolean, overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -604,24 +661,33 @@ fun SeriesDetailsHeader( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Text( - text = series.name ?: stringResource(R.string.unknown), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TitleOrLogo( + item = series, + showLogo = showLogo, modifier = Modifier .fillMaxWidth(.75f) - .padding(start = 8.dp), + .padding(start = HeaderUtils.startPadding), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) + QuickDetails( + series, + null, + Modifier.padding(start = HeaderUtils.startPadding), + ) + dto.studios?.let { + val studios = remember { series.studioNames } + GenreText( + studios, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) + } dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } dto.overview?.let { overview -> OverviewText( @@ -629,6 +695,14 @@ fun SeriesDetailsHeader( maxLines = 3, onClick = overviewOnClick, textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, ) } } @@ -636,52 +710,64 @@ fun SeriesDetailsHeader( } fun buildDialogForSeason( - context: Context, + resources: Resources, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { 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) }, ) + if (canDelete) { + add( + DialogItem( + resources.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } 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 a8a9d83f..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,51 +6,40 @@ 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.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.ChosenStreams 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.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.LoadingPage -import com.github.damontecres.wholphin.ui.components.chooseStream -import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.seasonEpisode -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.MediaStreamType -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 import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import kotlin.time.Duration @@ -88,54 +77,79 @@ fun SeriesOverview( ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current 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(listOf()) - val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) - val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) - 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) } } } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } - var chooseVersion by remember { mutableStateOf<DialogParams?>(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() - LaunchedEffect(episodes) { - episodes?.let { episodes -> - if (episodes is EpisodeList.Success) { - if (episodes.episodes.isNotEmpty()) { - // TODO focus on first episode when changing seasons? -// firstItemFocusRequester.requestFocus() - episodes.episodes.getOrNull(position.episodeRowIndex)?.let { - viewModel.refreshEpisode(it.id, position.episodeRowIndex) + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, currentPosition.episodeRowIndex) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog = itemId + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = viewModel::deleteItem, + onChooseVersion = { item, source -> + viewModel.savePlayVersion( + item, + source.id!!.toUUID(), + ) + }, + onChooseTracks = { result -> + viewModel.saveTrackSelection( + result.item, + result.itemPlayback, + result.trackIndex, + result.streamType, + ) + }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onClearChosenStreams = { + val focusedEpisode = + (state.episodes as? EpisodeList.Success) + ?.episodes + ?.getOrNull(currentPosition.episodeRowIndex) + if (focusedEpisode != null) { + viewModel.clearChosenStreams(focusedEpisode, it) } - } - } + }, + ) } - } - LaunchedEffect(position, episodes) { + LaunchedEffect(position, state.episodes) { val focusedEpisode = - (episodes as? EpisodeList.Success) + (state.episodes as? EpisodeList.Success) ?.episodes ?.getOrNull(position.episodeRowIndex) @@ -144,265 +158,181 @@ fun SeriesOverview( viewModel.lookupPeopleInEpisode(it) } } - val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val chosenStreams = state.chosenStreams - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference + + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { - LoadingPage() + 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 - else -> episodeRowFocusRequester - }, - "series_overview", - ) - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } + onPauseOrDispose { + viewModel.release() } + } - fun buildMoreForEpisode( - ep: BaseItem, - chosenStreams: ChosenStreams?, - fromLongClick: Boolean, - ): DialogParams = - DialogParams( - fromLongClick = fromLongClick, - title = series.name + " - " + ep.data.seasonEpisode, - items = - buildMoreDialogItems( - context = context, - item = ep, - watched = ep.data.userData?.played ?: false, - favorite = ep.data.userData?.isFavorite ?: false, - seriesId = series.id, - sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, - actions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched( - itemId, - watched, - position.episodeRowIndex, - ) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite( - itemId, - favorite, - position.episodeRowIndex, - ) - }, - onClickAddPlaylist = { - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = it - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ), - onChooseVersion = { - chooseVersion = - chooseVersionParams( - context, - ep.data.mediaSources!!, - ) { idx -> - val source = ep.data.mediaSources!![idx] - viewModel.savePlayVersion( - ep, - source.id!!.toUUID(), - ) - } - moreDialog = null - }, - onChooseTracks = { type -> - viewModel.streamChoiceService - .chooseSource( - ep.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - context = context, - streams = source.mediaStreams.orEmpty(), - type = type, - currentIndex = - if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index - } else { - chosenStreams?.subtitleStream?.index - }, - onClick = { trackIndex -> - viewModel.saveTrackSelection( - ep, - chosenStreams?.itemPlayback, - trackIndex, - type, - ) - }, - ) - } - }, - onShowOverview = { - overviewDialog = - ItemDetailsDialogInfo( - title = ep.name ?: context.getString(R.string.unknown), - overview = ep.data.overview, - genres = ep.data.genres.orEmpty(), - files = ep.data.mediaSources.orEmpty(), - ) - }, - onClearChosenStreams = { - viewModel.clearChosenStreams(ep, chosenStreams) - }, - ), - ) - - SeriesOverviewContent( - preferences = preferences, - series = series, - seasons = seasons, - episodes = episodes, - chosenStreams = chosenStreams, - peopleInEpisode = peopleInEpisode, - position = position, - firstItemFocusRequester = firstItemFocusRequester, - episodeRowFocusRequester = episodeRowFocusRequester, - castCrewRowFocusRequester = castCrewRowFocusRequester, - guestStarRowFocusRequester = guestStarRowFocusRequester, - 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 -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) - }, - 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, ) - } - }, - 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 -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) - } - }, - overviewOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) - } - }, - personOnClick = { - rowFocused = - if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - 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 -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = viewModel.streamChoiceService::chooseSource, + contextMenu = contextMenu, + preferredSubtitleLanguage = preferredSubtitleLanguage, + ) + } overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, ) } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } - chooseVersion?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { chooseVersion = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } showPlaylistDialog?.let { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -417,6 +347,7 @@ fun SeriesOverview( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -425,3 +356,4 @@ fun SeriesOverview( private const val EPISODE_ROW = 0 private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1 private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1 +private const val EXTRAS_ROW = GUEST_STAR_ROW + 1 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 86e2b398..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 @@ -30,38 +30,45 @@ import androidx.compose.runtime.mutableIntStateOf 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.Modifier -import androidx.compose.ui.draw.alpha 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.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 +import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.cards.BannerCard +import com.github.damontecres.wholphin.ui.cards.ExtrasRow 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.SeriesName +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 @@ -71,6 +78,7 @@ fun SeriesOverviewContent( series: BaseItem, seasons: List<BaseItem?>, episodes: EpisodeList, + seasonExtras: List<ExtrasItem>, chosenStreams: ChosenStreams?, peopleInEpisode: List<Person>, position: SeriesOverviewPosition, @@ -78,6 +86,7 @@ fun SeriesOverviewContent( episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester, + extrasRowFocusRequester: FocusRequester, onChangeSeason: (Int) -> Unit, onFocusEpisode: (Int) -> Unit, onClick: (BaseItem) -> Unit, @@ -88,11 +97,15 @@ fun SeriesOverviewContent( moreOnClick: () -> Unit, overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, + canDelete: (BaseItem) -> Boolean, + onConfirmDelete: (BaseItem) -> Unit, + onClickExtra: (Int, ExtrasItem) -> Unit, + onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } - var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } + var selectedTabIndex by rememberSaveable(position.seasonTabIndex) { mutableIntStateOf(position.seasonTabIndex) } LaunchedEffect(selectedTabIndex) { logTab("series_overview", selectedTabIndex) } @@ -108,14 +121,23 @@ 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) Box( modifier = @@ -134,34 +156,43 @@ fun SeriesOverviewContent( .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() .bringIntoViewRequester(bringIntoViewRequester), ) { val paddingValues = - if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 0.dp, end = 184.dp) - } else { - PaddingValues(start = 0.dp, end = 16.dp) + remember(preferences.appPreferences.interfacePreferences.showClock) { + if (preferences.appPreferences.interfacePreferences.showClock) { + PaddingValues(start = 0.dp, end = 184.dp) + } else { + PaddingValues(start = 0.dp, end = 16.dp) + } } TabRow( selectedTabIndex = selectedTabIndex, tabs = tabs, - onClick = { - selectedTabIndex = it - onChangeSeason.invoke(it) - requestFocusAfterSeason = true - }, - focusRequesters = focusRequesters, + onClick = + remember { + { + selectedTabIndex = it + currentOnChangeSeason(it) + requestFocusAfterSeason = true + } + }, modifier = Modifier .focusRequester(tabRowFocusRequester) .padding(paddingValues) + .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier) + TitleOrLogo( + item = series, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, @@ -203,7 +234,7 @@ fun SeriesOverviewContent( contentPadding = PaddingValues(horizontal = 16.dp), modifier = Modifier - .focusRestorer(firstItemFocusRequester) + .focusRestorer(episodeRowFocusRequester) // .focusRequester(episodeRowFocusRequester) .onFocusChanged { cardRowHasFocus = it.hasFocus @@ -244,14 +275,21 @@ fun SeriesOverviewContent( ).ifElse( episodeIndex == epPosition, Modifier.focusRequester(episodeRowFocusRequester), - ).ifElse( - episodeIndex != position.episodeRowIndex, - Modifier - .background( - Color.Black, - shape = RoundedCornerShape(8.dp), - ).alpha(dimming), - ).onFocusChanged { + ).background( + if (episodeIndex != position.episodeRowIndex) { + Color.Black + } else { + Color.Transparent + }, + shape = RoundedCornerShape(8.dp), + ).graphicsLayer { + alpha = + if (episodeIndex != position.episodeRowIndex) { + dimming + } else { + 1f + } + }.onFocusChanged { if (it.isFocused) { scope.launch { bringIntoViewRequester.bringIntoView() @@ -266,6 +304,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } @@ -292,25 +331,20 @@ fun SeriesOverviewContent( } } }, + canDelete = canDelete.invoke(ep), + onConfirmDelete = { onConfirmDelete.invoke(ep) }, + onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } - val castAndCrew = + val (guestStars, castAndCrew) = remember(peopleInEpisode) { - peopleInEpisode.filterNot { - it.type == PersonKind.GUEST_STAR - } - } - val guestStars = - remember(peopleInEpisode) { - peopleInEpisode.filter { - it.type == PersonKind.GUEST_STAR - } + peopleInEpisode.partition { it.type == PersonKind.GUEST_STAR } } AnimatedVisibility( @@ -346,6 +380,21 @@ fun SeriesOverviewContent( } } } + AnimatedVisibility( + visible = seasonExtras.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + ExtrasRow( + extras = seasonExtras, + onClickItem = onClickExtra, + onLongClickItem = { _, _ -> }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(extrasRowFocusRequester), + ) + } } } } 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 3d80aa9d..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 @@ -13,46 +13,56 @@ 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.AppPreferences 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.MediaReportService +import com.github.damontecres.wholphin.services.MediaManagementService 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.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.detail.ItemViewModel +import com.github.damontecres.wholphin.services.deleteItem +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 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 +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,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, @@ -86,14 +96,15 @@ 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, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, - ) : ItemViewModel(api) { + ) : ViewModel() { @AssistedFactory interface Factory { fun create( @@ -103,40 +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 peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) - val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + 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) - backdropService.submit(item) + 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(series)) { canDelete -> + _state.update { it.copy(canDeleteSeries = canDelete) } + } + } + 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, ) @@ -144,6 +159,7 @@ class SeriesViewModel seasonsDeferred.await().firstOrNull()?.let { loadEpisodesInternal( it.id, + it.indexNumber, null, null, ) @@ -153,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") @@ -172,69 +200,110 @@ class SeriesViewModel it.copy(seasonTabIndex = index.coerceAtLeast(0)) } } - } - 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, - ) + viewModelScope.launchIO { + val extras = extrasService.getExtras(seasonEpisodeIds.seasonId) + _state.update { it.copy(extras = extras) } } - 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 -> + val tv = + if (active) { + try { + seerrService + .getTvSeries(series) + ?.let { seerrService.createDiscoverItem(it) } + } catch (ex: Exception) { + Timber.e(ex) + null + } + } else { + null + } + _state.update { it.copy(discoverSeries = tv) } + } } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + if (deletedItem.item.data.seriesId == seriesId) { + Timber.d( + "Item %s deleted from series %s", + deletedItem.item.id, + seriesId, + ) + val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await() + _state.update { it.copy(seasons = seasons) } + } + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) } } fun onResumePage() { - viewModelScope.launchIO { - item.value?.let { - backdropService.submit(it) - val playThemeSongs = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.playThemeSongs - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + state.value.series.successValue?.let { item -> + viewModelScope.launchDefault { backdropService.submit(item) } + viewModelScope.launchDefault { + themeSongPlayer.playThemeFor(seriesId) + } + } + } + + fun refresh() { + state.value.series.successValue?.let { item -> + viewModelScope.launchIO { + (state.value.seasons as? ApiRequestPager<*>)?.refresh() } } } @@ -247,7 +316,8 @@ 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( parentId = series.id, @@ -255,13 +325,17 @@ 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( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -270,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 { @@ -300,6 +367,8 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, + ItemFields.PARENT_ID, ), ) Timber.v( @@ -311,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) } @@ -324,29 +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 + _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 - } - if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - (episodes as? EpisodeList.Success) - ?.let { - it.episodes.getOrNull(it.initialEpisodeIndex) - }?.let { lookupPeopleInEpisode(it) } - } + _state.update { it.copy(episodes = episodes) } + } + viewModelScope.launchIO { + val extras = extrasService.getExtras(seasonId) + _state.update { it.copy(extras = extras) } } } @@ -354,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 @@ -425,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( @@ -443,7 +536,6 @@ class SeriesViewModel navigationManager.navigateTo(destination) } - val chosenStreams = MutableLiveData<ChosenStreams?>(null) private var chosenStreamsJob: Job? = null fun lookUpChosenTracks( @@ -459,9 +551,7 @@ class SeriesViewModel item, userPreferencesService.getCurrent(), ) - withContext(Dispatchers.Main) { - chosenStreams.value = result - } + _state.update { it.copy(chosenStreams = result) } } } @@ -471,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) } } } @@ -491,7 +578,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -499,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) } } } @@ -518,17 +599,17 @@ 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) .content.people - ?.map { Person.fromDto(it, api) } + ?.map { Person.fromDto(context, it, api) } .orEmpty() PeopleInItem(item.id, list) @@ -537,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) } } } } @@ -551,6 +633,70 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + state.value.series.successValue?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + _state.update { it.copy(seasons = seasons) } + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = state.value.episodes as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + } + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { @@ -580,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 53b04161..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,101 +1,121 @@ package com.github.damontecres.wholphin.ui.discover -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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 = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - 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 new file mode 100644 index 00000000..fe6dd475 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt @@ -0,0 +1,329 @@ +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.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.launchDefault +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.DiscoverMovieRequestHandler +import com.github.damontecres.wholphin.util.DiscoverRequestPager +import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler +import com.github.damontecres.wholphin.util.TrendingRequestHandler +import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler +import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler +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.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber + +@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class) +class DiscoverRequestViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + 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 + } + + private val _state = MutableStateFlow(DiscoverRequestState()) + val state: StateFlow<DiscoverRequestState> = _state + + init { + viewModelScope.launchDefault { + 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() + } + 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>( + key = viewModelKey, + creationCallback = { + it.create( + destination.type, + destination.startIndex, + destination.initialFilter, + ) + }, + ), +) { + val state by viewModel.state.collectAsState() + + 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.Error -> { + ErrorMessage(s, Modifier) + } + + is DataLoadingState.Success<List<DiscoverItem?>> -> { + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + CardGrid( + initialPosition = state.startIndex, + pager = s.data, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onClickPlay = { _, _ -> }, + letterPosition = { 0 }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = false, + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + 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 new file mode 100644 index 00000000..bbf1dc18 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -0,0 +1,194 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusGroup +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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +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.data.model.DiscoverItem +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +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 +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState + +@Composable +fun DiscoverRow( + row: DiscoverRowData, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + enableViewMore: Boolean = false, + onClickViewMore: () -> Unit = {}, +) { + when (val state = row.items) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = row.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<DiscoverItem>> -> { + DiscoverItemRow( + title = row.title.getString(), + items = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onCardFocus = onCardFocus, + enableViewMore = enableViewMore, + onClickViewMore = onClickViewMore, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} + +@Composable +fun DiscoverItemRow( + title: String, + items: List<DiscoverItem?>, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + enableViewMore: Boolean, + modifier: Modifier = Modifier, + horizontalPadding: Dp = 16.dp, + onClickViewMore: () -> Unit = {}, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + var position by rememberInt() + + val currentOnClickItem by rememberUpdatedState(onClickItem) + val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) + val currentOnCardFocus by rememberUpdatedState(onCardFocus) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier.focusProperties { + onEnter = { + focusRequester.tryRequestFocus() + } + }, + ) { + ItemRowTitle(title) + + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(horizontalPadding), + contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusGroup() + .focusRestorer(firstFocus) + .focusRequester(focusRequester), + ) { + itemsIndexed(items) { index, item -> + val cardModifier = + remember(index, position) { + if (index == position) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + }.onFocusChanged { + if (it.isFocused) { + currentOnCardFocus.invoke(index) + } + } + } + + val onClick = + remember(index, item) { + { + position = index + if (item != null) currentOnClickItem(index, item) + } + } + + val onLongClick = + remember(index, item) { + { + position = index + if (item != null) currentOnLongClickItem(index, item) + } + } + + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = cardModifier, + ) + } + if (enableViewMore) { + item { + ViewMoreCard( + onClick = + remember { + { + position = items.size + onClickViewMore.invoke() + } + }, + onLongClick = {}, + modifier = + Modifier + .ifElse(items.size == position, Modifier.focusRequester(firstFocus)) + .onFocusChanged { + if (it.isFocused) { + currentOnCardFocus.invoke(items.size) + } + }, + ) + } + } + } + } +} 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 691b00cb..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 @@ -1,16 +1,18 @@ package com.github.damontecres.wholphin.ui.discover 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.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 import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -22,38 +24,47 @@ 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.res.stringResource +import androidx.compose.ui.platform.LocalDensity 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.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.cards.DiscoverItemCard -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.components.HeaderUtils import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString 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.google.common.cache.CacheBuilder +import com.github.damontecres.wholphin.util.DiscoverRequestType +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.api.client.ApiClient +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -62,38 +73,144 @@ class SeerrDiscoverViewModel constructor( @param:ApplicationContext private val context: Context, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, val navigationManager: NavigationManager, - private val api: ApiClient, 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 { backdropService.clearBackdrop() } fetchAndUpdateState(seerrService::discoverMovies) { - this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it)) + this.copy( + movies = + DiscoverRowData( + ResStringProvider(R.string.movies_title), + it, + DiscoverRequestType.DISCOVER_MOVIES, + ), + ) } fetchAndUpdateState(seerrService::discoverTv) { - this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it)) + this.copy( + tv = + DiscoverRowData( + ResStringProvider(R.string.tv_shows_title), + it, + DiscoverRequestType.DISCOVER_TV, + ), + ) } fetchAndUpdateState(seerrService::trending) { - this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it)) + this.copy( + trending = + DiscoverRowData( + ResStringProvider(R.string.trending), + it, + DiscoverRequestType.TRENDING, + ), + ) } fetchAndUpdateState(seerrService::upcomingMovies) { this.copy( upcomingMovies = - DiscoverRowData(context.getString(R.string.upcoming_movies), it), + DiscoverRowData( + ResStringProvider(R.string.upcoming_movies), + it, + DiscoverRequestType.UPCOMING_MOVIES, + ), ) } fetchAndUpdateState(seerrService::upcomingTv) { this.copy( upcomingTv = - DiscoverRowData(context.getString(R.string.upcoming_tv), it), + DiscoverRowData( + 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( @@ -117,64 +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 = - 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) - } - } - 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) + val EMPTY = + DiscoverRowData( + EmptyStringProvider, + DataLoadingState.Pending, + DiscoverRequestType.UNKNOWN, + ) } } @@ -184,25 +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) { @@ -221,43 +389,55 @@ 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 = 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), ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), + + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + + @Composable + fun discoverItemRow( + rowIndex: Int, + row: DiscoverRowData, ) { - itemsIndexed(rows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { DiscoverRow( row = row, onClickItem = { index, item -> @@ -265,73 +445,124 @@ fun SeerrDiscoverPage( viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + 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(), ) } } - } -} - -@Composable -fun DiscoverRow( - row: DiscoverRowData, - onClickItem: (Int, DiscoverItem) -> Unit, - onLongClickItem: (Int, DiscoverItem) -> Unit, - onCardFocus: (Int) -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, -) { - when (val state = row.items) { - is DataLoadingState.Error -> { - ErrorMessage(state.message, state.exception, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - Column( + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), + ) { + LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), ) { - Text( - text = row.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) + item { discoverItemRow(ROW_TRENDING, state.trending) } + + // Movies + item { discoverItemRow(ROW_MOVIES, state.movies) } + item { + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + 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( + type = DiscoverRequestType.DISCOVER_MOVIES, + initialFilter = + DiscoverFilter( + genreIds = listOf(genre.id.toInt()), + ), + titleOverride = + discoverGenreTitle( + genre.name, + SeerrItemType.MOVIE, + ), + startIndex = 0, + ), + ) + }, + 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) } } } - - is DataLoadingState.Success<List<DiscoverItem>> -> { - ItemRow( - title = row.title, - items = state.data, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = false, - modifier = - mod.onFocusChanged { - if (it.isFocused) { - onCardFocus.invoke(index) - } - }, - ) - }, - modifier = modifier.focusRequester(focusRequester), - ) - } } } 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 3a838815..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 @@ -64,7 +64,7 @@ class SeerrRequestsViewModel viewModelScope.launchIO { backdropService.clearBackdrop() } - seerrServerRepository.current + seerrServerRepository.connection .onEach { user -> state.update { it.copy(requests = DataLoadingState.Loading) } if (user != null) { @@ -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) { @@ -85,13 +85,13 @@ class SeerrRequestsViewModel seerrService.api.moviesApi .movieMovieIdGet( movieId = request.media.tmdbId, - ).let { DiscoverItem(it) } + ).let { seerrService.createDiscoverItem(it) } } SeerrItemType.TV -> { seerrService.api.tvApi .tvTvIdGet(tvId = request.media.tmdbId) - .let { DiscoverItem(it) } + .let { seerrService.createDiscoverItem(it) } } SeerrItemType.PERSON -> { @@ -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 -> { @@ -204,7 +206,7 @@ fun SeerrRequestsPage( showJumpButtons = false, showLetterButtons = false, spacing = 16.dp, - cardContent = @Composable { item, onClick, onLongClick, mod -> + cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> DiscoverItemCard( item = item?.item, onClick = onClick, @@ -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 cfb13ff7..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 @@ -1,14 +1,13 @@ package com.github.damontecres.wholphin.ui.main -import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable +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.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -16,64 +15,84 @@ 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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState 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.graphics.Color +import androidx.compose.ui.input.key.KeyEvent 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.font.FontWeight 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.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.BannerCard +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.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup +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.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.FocusableItemRow +import com.github.damontecres.wholphin.ui.components.HeaderUtils 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +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 @@ -86,80 +105,144 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { + LifecycleStartEffect(Unit) { viewModel.init() + onStopOrDispose { } } - val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) - val watchingRows by viewModel.watchingRows.observeAsState(listOf()) - val latestRows by viewModel.latestRows.observeAsState(listOf()) - - val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } + val state by viewModel.state.collectAsState() + val loading = state.loadingState + val refreshing = state.refreshState + val homeRows = state.homeRows when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { - var dialog by remember { mutableStateOf<DialogParams?>(null) } + var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } + + 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 + if (currentHomePrefs.clickToPlay && + homeRows.getOrNull(clickedPosition.row)?.isContinueWatchingNextUp == true + ) { + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } + } + } + val onLongClickItem = + remember { + { clickedPosition: RowColumn, item: BaseItem -> + position = clickedPosition + val row = + (homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success) + val canRemoveContinueWatching = + row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined + val canRemoveNextUp = + row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveContinueWatching = canRemoveContinueWatching, + canRemoveNextUp = canRemoveNextUp, + actions = + ContextMenuActions( + navigateTo = viewModel.navigationManager::navigateTo, + onClickWatch = viewModel::setWatched, + onClickFavorite = viewModel::setFavorite, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists() + showPlaylistDialog = itemId + }, + onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, + onDeleteItem = { + viewModel.deleteItem(position, it) + }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { + // Not supported on this page + }, + onShowOverview = { + overviewDialog = ItemDetailsDialogInfo(it) + }, + onClearChosenStreams = {}, + onClickRemoveFromNextUp = viewModel::removeFromNextUp, + ), + ) + } + } + val onClickPlay = + remember { + { _: RowColumn, item: BaseItem -> + 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, - onClickItem = { position, item -> - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { position, item -> - val dialogItems = - buildMoreDialogItemsForHome( - context = context, - item = item, - seriesId = item.data.seriesId, - playbackPosition = item.playbackPosition, - watched = item.played, - favorite = item.favorite, - actions = - MoreDialogActions( - navigateTo = viewModel.navigationManager::navigateTo, - onClickWatch = { itemId, played -> - viewModel.setWatched(itemId, played) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = itemId - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - ), - ) - dialog = - DialogParams( - title = item.title ?: "", - fromLongClick = true, - items = dialogItems, - ) - }, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - }, + position = position, + onFocusPosition = onFocusPosition, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = true, + onClickViewMore = onClickViewMore, modifier = modifier, ) - dialog?.let { params -> - DialogPopup( - params = params, - onDismissRequest = { dialog = null }, + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, ) } showPlaylistDialog?.let { itemId -> @@ -176,6 +259,7 @@ fun HomePage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, + onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -183,191 +267,253 @@ 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( homeRows: List<HomeRowLoadingState>, + position: RowColumn, + onFocusPosition: (RowColumn) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, + showLogo: Boolean, + showViewMore: Boolean, modifier: Modifier = Modifier, - onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, + listState: LazyListState = rememberLazyListState(), + takeFocus: Boolean = true, + showEmptyRows: Boolean = false, + headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> + HomePageHeader( + item = focusedItem, + showLogo = showLogo, + modifier = HeaderUtils.modifier, + ) + }, + onClickViewMore: (RowColumn, HomeRowLoadingState.Success) -> Unit = { _, _ -> }, ) { - var position by rememberPosition() val focusedItem = - position.let { - (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) + remember(homeRows, position) { + (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull( + position.column, + ) } - val listState = rememberLazyListState() - val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } + val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } - LaunchedEffect(homeRows) { - if (!firstFocused && homeRows.isNotEmpty()) { - if (position.row >= 0) { - val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - firstFocused = true - } else { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - firstFocused = true - delay(50) - listState.scrollToItem(it) - } + + val currentPosition by rememberUpdatedState(position) + val currentOnFocusPosition by rememberUpdatedState(onFocusPosition) + val currentOnClickPlay by rememberUpdatedState(onClickPlay) + + if (takeFocus) { + LaunchedEffect(homeRows) { + if (!firstFocused && homeRows.isNotEmpty()) { + if (position.row >= 0) { + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() + firstFocused = true + } else { + // Waiting for the first home row to load, then focus on it + homeRows + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } } - LaunchedEffect(position) { - if (position.row >= 0) { - listState.animateScrollToItem(position.row) - } - } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } Box(modifier = modifier) { - Column(modifier = Modifier.fillMaxSize()) { - HomePageHeader( - item = focusedItem, - modifier = - Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) - .fillMaxHeight(.33f), - ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), + Column( + modifier = + Modifier + .focusProperties { + onEnter = { + rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus() + } + }.fillMaxSize(), + ) { + headerComposable.invoke(focusedItem) + + val density = LocalDensity.current + val spaceAbovePx = + remember(density) { + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } + } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(homeRows) { rowIndex, row -> - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = + PaddingValues( + bottom = Cards.height2x3, + ), + modifier = + Modifier + .focusRestorer(), + ) { + itemsIndexed(homeRows) { rowIndex, row -> + val rowModifier = + Modifier + .animateItem(placementSpec = null) + .padding(bottom = 8.dp) + CompositionLocalProvider( + LocalBringIntoViewSpec provides defaultBringIntoViewSpec, + ) { + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + FocusableItemRow( + title = r.title.getString(), + subtitle = stringResource(R.string.loading), + modifier = rowModifier, + ) + } - is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } + is HomeRowLoadingState.Error -> { + FocusableItemRow( + title = r.title.getString(), + subtitle = r.localizedMessage, + isError = true, + modifier = rowModifier, + ) + } - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title, - items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke(RowColumn(rowIndex, index), item) - }, - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, - item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + val viewOptions = row.viewOptions + ItemRow( + title = row.title.getString(), + items = row.items, + onClickItem = + remember(rowIndex, onClickItem) { + { index, item -> + onClickItem.invoke( + RowColumn( + rowIndex, + index, + ), + item, + ) + } + }, + onLongClickItem = + remember(rowIndex, onLongClickItem) { + { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) + } + }, modifier = - cardModifier - .onFocusChanged { - if (it.isFocused) { - position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) + rowModifier + .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]), + horizontalPadding = viewOptions.spacing.dp, + cardContent = { index, item, cardModifier, onClick, onLongClick -> + val onFocus = + remember(rowIndex, index) { + { isFocused: Boolean -> + if (isFocused) { + currentOnFocusPosition( + RowColumn( + rowIndex, + index, + ), + ) + } } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), - ) + } + val onKey = + remember(item) { + { event: KeyEvent -> + if (isPlayKeyUp(event) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + currentOnClickPlay( + currentPosition, + item, + ) + true + } else { + false + } } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false + } + HomePageCardContent( + index = index, + item = item, + onClick = onClick, + onLongClick = onLongClick, + viewOptions = viewOptions, + modifier = + cardModifier + .onFocusChanged { onFocus(it.isFocused) } + .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, + ) }, - interactionSource = null, - cardHeight = Cards.height2x3, + onLongClick = {}, + viewOptions = viewOptions, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + currentOnFocusPosition.invoke( + RowColumn( + rowIndex, + r.items.size, + ), + ) + } + }, + ) + }, ) - }, - ) + } else if (showEmptyRows) { + FocusableItemRow( + title = r.title.getString(), + subtitle = stringResource(R.string.no_results), + modifier = rowModifier, + ) + } + } } } } @@ -397,17 +543,41 @@ fun HomePageContent( @Composable fun HomePageHeader( item: BaseItem?, + showLogo: Boolean, 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, + ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, + endsAt = item?.data?.endDate, + showLogo = showLogo, + logoImageUrl = rememberLogoUrl(item), modifier = modifier, ) } @@ -418,35 +588,34 @@ 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, ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } + TitleOrLogo( + title = title, + logoImageUrl = logoImageUrl, + showLogo = showLogo, + modifier = Modifier.fillMaxWidth(.75f), + ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .fillMaxWidth(.6f) - .fillMaxHeight(), + .fillMaxWidth(.6f), ) { - subtitle?.let { - EpisodeName(it) + if (subtitle != null) { + EpisodeName(subtitle) } - QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) val overviewModifier = Modifier .padding(0.dp) @@ -467,3 +636,131 @@ fun HomePageHeader( } } } + +@Composable +fun HomePageCardContent( + index: Int, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewOptions: HomeRowViewOptions, + modifier: Modifier, +) { + when (item?.type) { + BaseItemKind.GENRE -> { + GenreCard( + genreId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + + BaseItemKind.STUDIO -> { + StudioCard( + studioId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + + else -> { + val imageType = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeImageType.imageType + } else { + viewOptions.imageType.imageType + } + } + val ratio = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeAspectRatio.ratio + } else { + viewOptions.aspectRatio.ratio + } + } + val scale = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeContentScale.scale + } else { + viewOptions.contentScale.scale + } + } + if (viewOptions.showTitles) { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, + ) + } else { + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = null, + cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, + ) + } + } + } +} + +@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 0e571544..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 @@ -1,37 +1,54 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository 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.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.MediaReportService +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.ServerReportService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +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.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain 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.LoadingExceptionHandler 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 +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +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.ApiClient -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -41,115 +58,227 @@ class HomeViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - val api: ApiClient, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val navDrawerItemRepository: NavDrawerItemRepository, - 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 latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + private val latestNextUpService: LatestNextUpService, ) : ViewModel() { - val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) - val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) - val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) - val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) + private val _state = MutableStateFlow(HomeState.EMPTY) + val state: StateFlow<HomeState> = _state - private lateinit var preferences: UserPreferences + 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.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loadingState, - "Error loading home page", - ), - ) { + viewModelScope.launchIO { Timber.d("init HomeViewModel") - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.setValueOnMain(LoadingState.Loading) - } - refreshState.setValueOnMain(LoadingState.Loading) - this@HomeViewModel.preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - if (reload) { - backdropService.clearBackdrop() - } - try { - serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( + serverRepository.currentUserDto?.let { userDto -> + if (dataLoadingJob?.isActive == false) { + val settings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + 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, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, + settings.userId, ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = resume, - ), + } + } else { + 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, ) - } - if (nextUp.isNotEmpty()) { - add( + } catch (ex: InvalidStatusException) { + if (ex.status == 404) { + Timber.w(ex, "404 on row %s", row) HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, - ), + 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, ) } } } - - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } - - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest - } - loadingState.value = LoadingState.Success } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + + 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, + ) + } } - } catch (ex: Exception) { - Timber.e(ex) - if (!reload) { - loadingState.setValueOnMain(LoadingState.Error(ex)) - } else { - showToast(context, "Error refreshing home: ${ex.localizedMessage}") + _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)) } } } @@ -158,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() } } @@ -168,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() } } @@ -180,18 +309,72 @@ class HomeViewModel backdropService.submit(item) } } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.homeRows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + _state.update { + val newRow = + row.items.toMutableList().apply { + removeAt(position.column) + } + it.copy( + homeRows = + it.homeRows.toMutableList().apply { + set(position.row, row.copy(items = newRow)) + }, + ) + } + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun removeFromNextUp(item: BaseItem) { + if (item.type == BaseItemKind.EPISODE) { + viewModelScope.launchDefault { + serverRepository.currentUser?.id?.let { userId -> + latestNextUpService.removeFromNextUp(userId, item) + init() + } + } + } else { + Timber.w("Item is not an episode %s", item.id) + } + } } -val supportedLatestCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, - // Exclude Live TV because a recording folder view will be used instead - null, // Recordings & mixed collection types - ) +data class HomeState( + val loadingState: LoadingState, + val refreshState: LoadingState, + val homeRows: List<HomeRowLoadingState>, + val settings: HomePageResolvedSettings, +) { + companion object { + val EMPTY = + HomeState( + LoadingState.Pending, + LoadingState.Pending, + emptyList(), + HomePageResolvedSettings.EMPTY, + ) + } +} -data class LatestData( - val title: String, - val request: GetLatestMediaRequest, -) +/** + * Whether a row is a "is watching" type + */ +private fun isWatchingRow(row: HomeRowConfig) = + row is HomeRowConfig.ContinueWatching || + row is HomeRowConfig.NextUp || + row is HomeRowConfig.ContinueWatchingCombined 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 b5839aad..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ /dev/null @@ -1,565 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import androidx.activity.compose.BackHandler -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.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.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.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.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 seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery) - - private var currentQuery: String? = null - - 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.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 { - 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 { DiscoverItem(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 COLLECTION_ROW = MOVIE_ROW + 1 -private const val SERIES_ROW = COLLECTION_ROW + 1 -private const val EPISODE_ROW = SERIES_ROW + 1 -private const val SEERR_ROW = EPISODE_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 context = LocalContext.current - 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 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 = context.getString(R.string.movies), - result = movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = context.getString(R.string.collections), - result = collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = context.getString(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 = context.getString(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, - modifier = mod.padding(horizontal = 8.dp), - ) - }, - ) - searchResultRow( - title = context.getString(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( - title: String, - 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, - modifier = mod, - ) - }, -) { - item { - when (val r = result) { - is SearchResult.Error -> { - SearchResultPlaceholder( - title = title, - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = Modifier, - ) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = title, - message = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = title, - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { - ItemRow( - title = title, - items = r.items, - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = cardContent, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = title, - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { - ItemRow( - title = 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 new file mode 100644 index 00000000..c72d69a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -0,0 +1,137 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.itemsIndexed +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.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.SuggestionsWorker +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.CollectionType + +@Composable +fun HomeLibraryRowTypeList( + library: Library, + onClick: (LibraryRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { + val items = remember(library) { getSupportedRowTypes(library) } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row_for, library.name)) + HomeSettingsLazyColumn( + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(items) { index, rowType -> + ListItem( + selected = false, + headlineContent = { + Text( + text = stringResource(rowType.stringId), + ) + }, + onClick = { onClick.invoke(rowType) }, + modifier = + Modifier + .fillMaxWidth() + .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} + +fun getSupportedRowTypes(library: Library): List<LibraryRowType> { + val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null + return when { + library.isRecordingFolder -> { + listOf( + LibraryRowType.RECENTLY_RECORDED, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.LIVETV -> { + listOf( + LibraryRowType.TV_CHANNELS, + LibraryRowType.TV_PROGRAMS, + ) + } + + library.collectionType == CollectionType.TVSHOWS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + LibraryRowType.STUDIOS, + ) + } + + supportsSuggestions -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.BOXSETS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.COLLECTION, + ) + } + + library.collectionType == CollectionType.PLAYLISTS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.PLAYLIST, + ) + } + + else -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + ) + } + } +} + +enum class LibraryRowType( + @param:StringRes val stringId: Int, +) { + RECENTLY_ADDED(R.string.recently_added), + RECENTLY_RELEASED(R.string.recently_released), + SUGGESTIONS(R.string.suggestions), + GENRES(R.string.genres), + STUDIOS(R.string.studios), + TV_CHANNELS(R.string.channels), + 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 new file mode 100644 index 00000000..922ae3cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -0,0 +1,313 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.itemsIndexed +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.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.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( + val continueWatching: HomeRowViewOptions, + val movieLibrary: HomeRowViewOptions, + val tvLibrary: HomeRowViewOptions, + val videoLibrary: HomeRowViewOptions, + val photoLibrary: HomeRowViewOptions, + val musicLibrary: HomeRowViewOptions, + val playlist: HomeRowViewOptions, + val liveTv: HomeRowViewOptions, + val genreSize: Int, +) { + fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = + when (collectionType) { + CollectionType.MOVIES -> movieLibrary + + CollectionType.TVSHOWS -> tvLibrary + + CollectionType.MUSICVIDEOS -> videoLibrary + + CollectionType.TRAILERS -> videoLibrary + + CollectionType.HOMEVIDEOS -> videoLibrary + + CollectionType.BOXSETS -> movieLibrary + + CollectionType.PHOTOS -> photoLibrary + + CollectionType.LIVETV -> liveTv + + CollectionType.MUSIC -> musicLibrary + + CollectionType.UNKNOWN, + CollectionType.BOOKS, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + -> 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( + continueWatching = HomeRowViewOptions(), + movieLibrary = HomeRowViewOptions(), + tvLibrary = HomeRowViewOptions(), + videoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + musicLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + ), + playlist = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = HomeRowViewOptions.genreDefault.heightDp, + ) + } + + val WholphinCompact by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = height, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } + + val SeriesThumbs by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + useSeries = true, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } + + val EpisodeThumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + showTitles = true, + useSeries = false, + episodeImageType = ViewOptionImageType.PRIMARY, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + musicLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } + } +} + +@Composable +fun HomeRowPresetsContent( + onApply: (HomeRowPresets) -> Unit, + modifier: Modifier = Modifier, +) { + val presets = + listOf( + stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, + stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, + stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, + stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, + ) + + val focusRequesters = remember { List(presets.size) { FocusRequester() } } + LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.display_presets)) + HomeSettingsLazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[0]), + ) { + itemsIndexed(presets) { index, (title, preset) -> + HomeSettingsListItem( + selected = false, + headlineText = title, + onClick = { + onApply.invoke(preset) + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} 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 new file mode 100644 index 00000000..48901b0f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -0,0 +1,464 @@ +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 +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSliderPreference +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(), +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(title) + HomeSettingsLazyColumn { + preferenceOptions.forEachIndexed { groupIndex, prefGroup -> + if (preferenceOptions.size > 1) { + item { + SubTitleText(stringResource(prefGroup.title)) + } + } + itemsIndexed(prefGroup.preferences) { index, pref -> + pref as AppPreference<HomeRowViewOptions, 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, + onClickPreference = { pref -> + when (pref) { + Options.ViewOptionsReset -> { + onViewOptionsChange.invoke(defaultViewOptions) + } + + Options.ViewOptionsApplyAll -> { + onApplyApplyAll.invoke() + } + + Options.ViewOptionsUseThumb -> { + onViewOptionsChange.invoke( + viewOptions.copy( + heightDp = Cards.HEIGHT_EPISODE, + spacing = 20, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + episodeContentScale = PrefContentScale.FIT, + ), + ) + } + } + }, + modifier = + Modifier + .ifElse( + groupIndex == 0 && index == 0, + Modifier.focusRequester(firstFocus), + ), + ) + } + } + 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)), + ) + }, + ) + } + } +} + +internal object Options { + val ViewOptionsCardHeight = + AppSliderPreference<HomeRowViewOptions>( + title = R.string.height, + defaultValue = Cards.HEIGHT_2X3_DP.toLong(), + min = 64L, + max = Cards.HEIGHT_2X3_DP + 64L, + interval = 4, + getter = { it.heightDp.toLong() }, + setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) }, + ) + val ViewOptionsSpacing = + AppSliderPreference<HomeRowViewOptions>( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference<HomeRowViewOptions, PrefContentScale>( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference<HomeRowViewOptions, AspectRatio>( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsShowTitles = + AppSwitchPreference<HomeRowViewOptions>( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) + + val ViewOptionsUseSeries = + AppSwitchPreference<HomeRowViewOptions>( + title = R.string.use_series, + defaultValue = true, + getter = { it.useSeries }, + setter = { vo, value -> vo.copy(useSeries = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsApplyAll = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.apply_all_rows, + ) + + val ViewOptionsReset = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.reset, + ) + + val ViewOptionsUseThumb = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.use_thumb_images, + ) + + val ViewOptionsEpisodeContentScale = + AppChoicePreference<HomeRowViewOptions, PrefContentScale>( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.episodeContentScale }, + setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsEpisodeAspectRatio = + AppChoicePreference<HomeRowViewOptions, AspectRatio>( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.episodeAspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsEpisodeImageType = + AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.episodeImageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ViewOptionsUseSeries, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( +// ViewOptionsApplyAll, + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val OPTIONS_EPISODES = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ), + ), + PreferenceGroup( + title = R.string.for_episodes, + preferences = + listOf( + ViewOptionsUseSeries, + ViewOptionsEpisodeImageType, + ViewOptionsEpisodeAspectRatio, + ViewOptionsEpisodeContentScale, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val GENRE_OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsReset, + ), + ), + ) +} 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 new file mode 100644 index 00000000..439b2505 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +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.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.ifElse + +@Composable +fun HomeSettingsAddRow( + libraries: List<Library>, + showDiscover: Boolean, + onClick: (Library) -> Unit, + onClickMeta: (MetaRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row)) + HomeSettingsLazyColumn( + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed( + listOf( + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.library)) + HorizontalDivider() + } + itemsIndexed(libraries) { index, library -> + HomeSettingsListItem( + selected = false, + headlineText = library.name, + onClick = { onClick.invoke(library) }, + modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.more)) + HorizontalDivider() + } + itemsIndexed( + listOf( + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier, + ) + } + if (showDiscover) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.DISCOVER.stringId), + onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) }, + modifier = Modifier, + ) + } + } + } + } +} + +enum class MetaRowType( + @param:StringRes val stringId: Int, +) { + CONTINUE_WATCHING(R.string.continue_watching), + NEXT_UP(R.string.next_up), + COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), + FAVORITES(R.string.favorites), + DISCOVER(R.string.discover), + COLLECTION(R.string.collection), + PLAYLIST(R.string.playlist), +} 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 new file mode 100644 index 00000000..ae958a93 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -0,0 +1,41 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** + * Tracking the pages for selecting and configuring rows + */ +@Serializable +sealed interface HomeSettingsDestination : NavKey { + @Serializable + data object RowList : HomeSettingsDestination + + @Serializable + data object AddRow : HomeSettingsDestination + + @Serializable + data class ChooseRowType( + val library: Library, + ) : HomeSettingsDestination + + @Serializable + data class RowSettings( + val rowId: Int, + ) : HomeSettingsDestination + + @Serializable + data object ChooseFavorite : HomeSettingsDestination + + @Serializable + data object ChooseDiscover : HomeSettingsDestination + + @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 new file mode 100644 index 00000000..96c7583c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.itemsIndexed +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.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +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() }, +) { + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText( + stringResource(R.string.add_row_for, stringResource(R.string.favorites)), + ) + HomeSettingsLazyColumn( + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(favoriteTypeOptions) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(formatTypeName(type)), + onClick = { onClick.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} 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 new file mode 100644 index 00000000..74c546b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.HorizontalDivider +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.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsGlobal( + preferences: AppPreferences, + onPreferenceChange: (AppPreferences) -> Unit, + onClickResize: (Int) -> Unit, + onClickViewNextUp: () -> 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(), + ) + HorizontalDivider() + HomeSettingsLazyColumn( + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + ComposablePreference( + preference = AppPreference.HomePageItems, + value = AppPreference.HomePageItems.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ComposablePreference( + preference = AppPreference.RewatchNextUp, + value = AppPreference.RewatchNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier, + ) + } + item { + ComposablePreference( + preference = AppPreference.MaxDaysNextUp, + value = AppPreference.MaxDaysNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + 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( + selected = false, + headlineText = stringResource(R.string.increase_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(1) }, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.decrease_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(-1) }, + modifier = Modifier, + ) + } + item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.view_removed_next_up), + leadingContent = { + Text( + text = stringResource(R.string.fa_eye), + fontFamily = FontAwesome, + ) + }, + onClick = onClickViewNextUp, + 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 new file mode 100644 index 00000000..ec35cdb9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -0,0 +1,83 @@ +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 +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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 +import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle + +@Composable +@NonRestartableComposable +fun HomeSettingsListItem( + selected: Boolean, + onClick: () -> Unit, + headlineText: String, + 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 = 3.dp, + shape: ListItemShape = ListItemDefaults.shape(), + colors: ListItemColors = ListItemDefaults.colors(), + scale: ListItemScale = ListItemDefaults.scale(), + border: ListItemBorder = ListItemDefaults.border(), + glow: ListItemGlow = ListItemDefaults.glow(), + interactionSource: MutableInteractionSource? = null, +) = ListItem( + selected = selected, + onClick = onClick, + headlineContent = { + PreferenceTitle(headlineText) + }, + 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, +) + +@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 new file mode 100644 index 00000000..0bd6b7db --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -0,0 +1,429 @@ +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 +import androidx.compose.foundation.layout.Box +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.layout.width +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.surfaceColorAtElevation +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.AppPreferences +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.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 +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber + +val settingsWidth = 360.dp + +@Composable +fun HomeSettingsPage( + preferences: UserPreferences, + modifier: Modifier, + viewModel: HomeSettingsViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) + 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( + scrollToBottom: Boolean = true, + func: () -> Job, + ) { + scope.launch(ExceptionHandler(autoToast = true)) { + while (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + func.invoke().join() + if (scrollToBottom) { + listState.animateScrollToItem(state.rows.lastIndex) + } + } + } + + BackHandler(settingsHaveChanged) { + showSaveDialog = true + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .width(settingsWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface), + ) { + NavDisplay( + backStack = backStack, +// onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + entryProvider = { key -> + val dest = key as HomeSettingsDestination + NavEntry(dest, contentKey = key.toString()) { + val destModifier = + Modifier + .fillMaxSize() + .padding(8.dp) + when (dest) { + HomeSettingsDestination.RowList -> { + HomeSettingsRowList( + state = state, + onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) }, + onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) }, + onClickPresets = { backStack.add(HomeSettingsDestination.Presets) }, + onClickMove = viewModel::moveRow, + onClickDelete = viewModel::deleteRow, + onClick = { index, row -> + backStack.add(RowSettings(row.id)) + scope.launch(ExceptionHandler()) { + Timber.v("Scroll to $index") + listState.animateScrollToItem(index) + // Update backdrop to first item in the row + (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success) + ?.items + ?.firstOrNull() + ?.let { + viewModel.updateBackdrop(it) + } + position = RowColumn(index, 0) + } + }, + onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, + modifier = destModifier, + ) + } + + is HomeSettingsDestination.AddRow -> { + HomeSettingsAddRow( + libraries = state.libraries, + showDiscover = discoverEnabled, + onClick = { backStack.add(ChooseRowType(it)) }, + onClickMeta = { + when (it) { + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + -> { + addRow { viewModel.addRow(it) } + } + + MetaRowType.FAVORITES -> { + backStack.add(HomeSettingsDestination.ChooseFavorite) + } + + MetaRowType.DISCOVER -> { + backStack.add(HomeSettingsDestination.ChooseDiscover) + } + + MetaRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + MetaRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + } + }, + modifier = destModifier, + ) + } + + is ChooseRowType -> { + HomeLibraryRowTypeList( + library = dest.library, + onClick = { type -> + when (type) { + LibraryRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + LibraryRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + + else -> { + addRow { viewModel.addRow(dest.library, type) } + } + } + }, + modifier = destModifier, + ) + } + + is RowSettings -> { + val row = + state.rows + .first { it.id == dest.rowId } + val preferenceOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.ContinueWatchingCombined, + -> Options.OPTIONS_EPISODES + + is HomeRowConfig.Genres -> Options.GENRE_OPTIONS + + else -> Options.OPTIONS + } + } + val defaultViewOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault + else -> HomeRowViewOptions() + } + } + HomeRowSettings( + title = row.title.getString(), + preferenceOptions = preferenceOptions, + viewOptions = row.config.viewOptions, + defaultViewOptions = defaultViewOptions, + onViewOptionsChange = { + viewModel.updateViewOptions(dest.rowId, it) + }, + onApplyApplyAll = { + 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, + ) + } + } + }, + ) + } + + HomeSettingsDestination.ChooseDiscover -> { + TODO() + } + + HomeSettingsDestination.ChooseFavorite -> { + val favoriteTypeOptions = + remember { state.libraries.possibleFavoriteTypes(true) } + HomeSettingsFavoriteList( + favoriteTypeOptions = favoriteTypeOptions, + onClick = { type -> + addRow { viewModel.addFavoriteRow(type) } + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.GlobalSettings -> { + val preferences by + viewModel.preferencesDataStore.data.collectAsState( + AppPreferences.getDefaultInstance(), + ) + + HomeSettingsGlobal( + preferences = preferences, + onPreferenceChange = { newPrefs -> + scope.launchIO { + viewModel.preferencesDataStore.updateData { newPrefs } + } + }, + onClickResize = { viewModel.resizeCards(it) }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.SourceSettings -> { + HomeSettingsSourcePage( + source = state.source, + onClickSave = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_server_settings) { + viewModel.saveToRemote() + } + }, + onClickLoad = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemote() + } + }, + serverPluginActive = serverPluginActive, + onClickLoadPlugin = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemotePlugin() + } + }, + onClickLoadWeb = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemoteWeb() + } + }, + onClickReset = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + addRow(false) { viewModel.resetToDefault() } + } + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.Presets -> { + HomeRowPresetsContent( + onApply = viewModel::applyPreset, + modifier = destModifier, + ) + } + } + } + }, + ) + } + HomePageContent( + loadingState = state.loading, + homeRows = state.rowData, + position = position, + onFocusPosition = { position = it }, + onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + showClock = false, + onUpdateBackdrop = viewModel::updateBackdrop, + listState = listState, + takeFocus = false, + showEmptyRows = true, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = false, + modifier = + Modifier + .fillMaxHeight() + .weight(1f), + ) + } + showConfirmDialog?.let { (body, onConfirm) -> + ConfirmDialog( + title = stringResource(R.string.confirm), + body = stringResource(body), + onCancel = { showConfirmDialog = null }, + onConfirm = { + onConfirm.invoke() + showConfirmDialog = null + }, + ) + } + searchForDialog?.let { searchType -> + SearchForDialog( + onDismissRequest = { searchForDialog = null }, + searchType = searchType, + onClick = { + searchForDialog = null + addRow { viewModel.addRow(searchType, it) } + }, + ) + } + if (showRemovedNextUpDialog) { + BasicDialog( + onDismissRequest = { showRemovedNextUpDialog = false }, + ) { + RemovedNextUpContent( + modifier = Modifier.padding(16.dp), + ) + } + } + 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( + @param:StringRes val body: Int, + val onConfirm: () -> Unit, +) 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 new file mode 100644 index 00000000..72289463 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -0,0 +1,311 @@ +package com.github.damontecres.wholphin.ui.main.settings + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +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 +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.platform.LocalFocusManager +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 +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.HomeRowConfigDisplay +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch + +enum class MoveDirection { + UP, + 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, + onClick: (Int, HomeRowConfigDisplay) -> Unit, + onClickAdd: () -> Unit, + onClickSettings: () -> Unit, + 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 = POS_PRESETS + 1 // +1 for divider + val focusRequesters = + remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } + + var position by rememberInt(0) + + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Column(modifier = modifier) { + TitleText(stringResource(R.string.customize_home)) + HomeSettingsLazyColumn( + state = listState, + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[POS_ADD_ROW]), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.add_row), + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + onClick = { + position = POS_ADD_ROW + onClickAdd.invoke() + }, + 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 { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.settings), + leadingContent = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + }, + onClick = { + position = POS_SETTINGS + onClickSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.display_presets), + supportingContent = { + Text( + text = stringResource(R.string.display_presets_description), + ) + }, + leadingContent = { + Text( + text = stringResource(R.string.fa_sliders), + fontFamily = FontAwesome, + ) + }, + onClick = { + position = POS_PRESETS + onClickPresets.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), + ) + } + item { + TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})") + HorizontalDivider() + } + itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> + HomeRowConfigContent( + config = row, + moveUpAllowed = index > 0, + moveDownAllowed = index != state.rows.lastIndex, + deleteAllowed = state.rows.size > 1, + onClickMove = { + onClickMove.invoke(it, index) + scope.launch { + val scrollIndex = + itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1 + if (scrollIndex < listState.firstVisibleItemIndex || + scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex + ) { + listState.animateScrollToItem(scrollIndex) + } + } + }, + onClickDelete = { + if (index != state.rows.lastIndex) { + focusManager.moveFocus(FocusDirection.Down) + } else { + focusManager.moveFocus(FocusDirection.Up) + } + onClickDelete.invoke(index) + }, + onClick = { + position = itemsBeforeRows + index + onClick.invoke(index, row) + }, + modifier = + Modifier + .fillMaxWidth() + .animateItem() + .focusRequester(focusRequesters[itemsBeforeRows + index]), + ) + } + } + } +} + +@Composable +fun HomeRowConfigContent( + config: HomeRowConfigDisplay, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + deleteAllowed: Boolean, + onClick: () -> Unit, + onClickMove: (MoveDirection) -> Unit, + onClickDelete: () -> Unit, + modifier: Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + HomeSettingsListItem( + selected = false, + headlineText = config.title.getString(), + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + Button( + onClick = onClickDelete, + enabled = deleteAllowed, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +@NonRestartableComposable +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, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} 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 new file mode 100644 index 00000000..1082a23f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -0,0 +1,967 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Immutable +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.HomePageSettings +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres +import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms +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.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.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 +import kotlinx.serialization.Serializable +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.serializer.UUIDSerializer +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.properties.Delegates + +@HiltViewModel +class HomeSettingsViewModel + @Inject + constructor( + @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>, + 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 originalSettings: HomePageSettings? = null + + init { + viewModelScope.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 } + 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, + ) + } + fetchRowData() + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + private suspend fun fetchRowData() { + val limit = 8 + val semaphore = Semaphore(4) + val rows = + serverRepository.currentUserDto?.let { userDto -> + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + state.value + .let { state -> + state.rows + .map { row -> + viewModelScope.async(WholphinDispatchers.IO) { + semaphore.withPermit { + try { + homeDataService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + isRefresh = false, + ) + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error(row.title, exception = ex) + } + } + } + } + }.awaitAll() + } + rows?.let { rows -> + rows + .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + it as HomeRowLoadingState.Success + it.items.firstOrNull()?.let { + Timber.v("Updating backdrop") + updateBackdrop(it) + } + } + updateState { + it.copy(loading = LoadingState.Success, rowData = rows) + } + } + } + + private fun <T> List<T>.move( + direction: MoveDirection, + index: Int, + ): List<T> = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + + fun moveRow( + direction: MoveDirection, + index: Int, + ) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.move(direction, index) + val rowData = it.rowData.move(direction, index) + it.copy( + rows = rows, + rowData = rowData, + ) + } + } +// viewModelScope.launchIO { fetchRowData() } + } + + fun deleteRow(index: Int) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.toMutableList().apply { removeAt(index) } + val rowData = + if (index in it.rowData.indices) { + it.rowData.toMutableList().apply { removeAt(index) } + } else { + it.rowData + } + it.copy( + rows = rows, + rowData = rowData, + ) + } + } + } + + fun addRow(type: MetaRowType): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (type) { + MetaRowType.CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.continue_watching), + config = ContinueWatching(), + ) + } + + MetaRowType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.next_up), + config = NextUp(), + ) + } + + MetaRowType.COMBINED_CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = ResStringProvider(R.string.combine_continue_next), + config = ContinueWatchingCombined(), + ) + } + + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Should use a different addRow() instead") + } + + MetaRowType.DISCOVER -> { + TODO() + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + library: Library, + rowType: LibraryRowType, + ): Job = + viewModelScope.launchIO { + val viewOptions = viewOptionsForCollectionType(library.collectionType) + val id = idCounter++ + val newRow = + when (rowType) { + LibraryRowType.RECENTLY_ADDED -> { + val title = + library.name.let { + ResArgStringProvider( + R.string.recently_added_in, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId, viewOptions), + ) + } + + LibraryRowType.RECENTLY_RELEASED -> { + val title = + library.name.let { + ResArgStringProvider( + R.string.recently_released_in, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyReleased(library.itemId, viewOptions), + ) + } + + LibraryRowType.GENRES -> { + val title = + library.name.let { ResArgStringProvider(R.string.genres_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Genres(library.itemId), + ) + } + + LibraryRowType.STUDIOS -> { + val title = + library.name.let { ResArgStringProvider(R.string.studios_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = HomeRowConfig.Studios(library.itemId), + ) + } + + LibraryRowType.SUGGESTIONS -> { + val title = + library.name.let { + ResArgStringProvider( + R.string.suggestions_for, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = Suggestions(library.itemId, viewOptions), + ) + } + + LibraryRowType.TV_CHANNELS -> { + val title = ResStringProvider(R.string.channels) + HomeRowConfigDisplay( + id = id, + title = title, + config = + TvChannels( + viewOptions = HomeRowViewOptions.liveTvDefault, + ), + ) + } + + LibraryRowType.TV_PROGRAMS -> { + val title = ResStringProvider(R.string.watch_live) + HomeRowConfigDisplay( + id = id, + title = title, + config = TvPrograms(), + ) + } + + LibraryRowType.RECENTLY_RECORDED -> { + val title = getRecentlyAddedTitle(library.name) + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } + + LibraryRowType.COLLECTION, + LibraryRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Use different addRow") + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addFavoriteRow(type: BaseItemKind): Job = + viewModelScope.launchIO { + Timber.v("Adding favorite row for $type") + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(type)), + ), + config = + HomeRowConfig.Favorite( + kind = type, + viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type), + ), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + type: BaseItemKind, + parent: BaseItem, + ) = viewModelScope.launchIO { + Timber.v("Adding %s row for %s", type, parent.id) + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(parent.name ?: ""), + config = + HomeRowConfig.ByParent( + parentId = parent.id, + recursive = false, + ), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun updateViewOptions( + rowId: Int, + viewOptions: HomeRowViewOptions, + ) { + viewModelScope.launchIO { + var fetchData = false + updateState { + val index = it.rows.indexOfFirst { it.id == rowId } + val config = it.rows[index].config + val newRowConfig = config.updateViewOptions(viewOptions) + val newRow = it.rows[index].copy(config = newRowConfig) + if (config.viewOptions.useSeries != viewOptions.useSeries) { + fetchData = true + } + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, newRow) + }, + rowData = + it.rowData.toMutableList().apply { + val row = it.rowData[index] + val newRow = + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + set(index, newRow) + }, + ) + } + if (fetchData) { + fetchRowData() + } + } + } + + fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) { + viewModelScope.launchIO { + updateState { + it.copy( + rowData = + it.rowData.toMutableList().map { row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun saveToRemote(): Job = + viewModelScope.launchIO { + serverRepository.currentUser?.let { user -> + Timber.d("Saving home settings to remote") + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToRemote") + homeSettingsService.saveToServer(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.SERVER_PROFILE, + settings, + ) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + + fun loadFromRemote() { + viewModelScope.launchIO { + serverRepository.currentUser?.let { user -> + Timber.d("Loading home settings from remote") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.loadFromServer(user.id) + 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.SERVER_PROFILE, + rows = newRows, + ) + } + } else { + Timber.v("No remote settings") + showToast(context, "No server-side 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 loadFromRemoteWeb() { + viewModelScope.launchIO { + serverRepository.currentUser?.let { user -> + Timber.d("Loading home settings from web") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.parseFromWebConfig(user.id) + if (result != null) { + Timber.v("Got web settings") + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + source = HomePageSettingsSource.LOCAL, + rows = result.rows, + ) + } + } else { + Timber.v("No web settings") + showToast(context, "No server-side web settings found") + } + fetchRowData() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + 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") + homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.LOCAL, + settings, + ) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + + private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { + _state.update { + update.invoke(it) + } + } + + fun resizeCards(relative: Int) { + viewModelScope.launchIO { + updateState { + val newRows = + it.rows.toMutableList().map { row -> + val vo = row.config.viewOptions + val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative)) + row.copy(config = row.config.updateViewOptions(newVo)) + } + it.copy( + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun resetToDefault() = + viewModelScope.launchIO { + val user = serverRepository.currentUser ?: return@launchIO + _state.update { it.copy(loading = LoadingState.Loading) } + 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( + source = result.source, + rows = result.rows, + rowData = emptyList(), + ) + } + fetchRowData() + } + + private suspend fun showSaveToast() = + showToast( + context, + context.getString(R.string.settings_saved), + Toast.LENGTH_SHORT, + ) + + fun applyPreset(preset: HomeRowPresets) { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val state = state.value + + val typeCache = mutableMapOf<UUID, CollectionType?>() + + suspend fun getCollectionType(itemId: UUID): CollectionType = + typeCache.getOrPut(itemId) { + state.libraries + .firstOrNull { it.itemId == itemId } + ?.collectionType + ?: api.userLibraryApi + .getItem(itemId) + .content.collectionType ?: CollectionType.UNKNOWN + } ?: CollectionType.UNKNOWN + + val newRows = + state.rows.map { + val newConfig = + when (it.config) { + is ContinueWatching, + is NextUp, + is ContinueWatchingCombined, + -> { + it.config.updateViewOptions(preset.continueWatching) + } + + is HomeRowConfig.ByParent -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Favorite -> { + val viewOptions = preset.getByItemType(it.config.kind) + it.config.updateViewOptions(viewOptions) + } + + is Genres -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + + is HomeRowConfig.Studios -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + + is HomeRowConfig.GetItems -> { + it.config + } + + is RecentlyAdded -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is RecentlyReleased -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Recordings -> { + it.config.updateViewOptions(preset.tvLibrary) + } + + is Suggestions -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.TvPrograms -> { + it.config.updateViewOptions(preset.liveTv) + } + + is HomeRowConfig.TvChannels -> { + it.config.updateViewOptions(preset.liveTv) + } + + is HomeRowConfig.CustomEndpoint -> { + it.config + } + } + it.copy(config = newConfig) + } + + _state.update { + it.copy( + loading = LoadingState.Success, + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + + 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>, +) { + companion object { + val EMPTY = + HomePageSettingsState( + LoadingState.Pending, + HomePageSettingsSource.UNSET, + emptyList(), + emptyList(), + emptyList(), + ) + } +} + +@Immutable +@Serializable +data class Library( + @Serializable(UUIDSerializer::class) val itemId: UUID, + val name: String, + val type: BaseItemKind, + val collectionType: CollectionType, + val isRecordingFolder: Boolean, +) 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 new file mode 100644 index 00000000..e7b3c07c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt @@ -0,0 +1,258 @@ +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +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.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.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.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +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.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +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.model.api.ImageType +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class RemovedNextUpContentViewModel + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : ViewModel() { + private val mutex = Mutex() + + private val _state = MutableStateFlow(RemovedNextUpState()) + val state: StateFlow<RemovedNextUpState> = _state + + init { + viewModelScope.launchDefault { + serverRepository.currentUserFlow.collectLatest { user -> + _state.update { RemovedNextUpState() } + if (user == null) { + return@collectLatest + } + try { + val removed = latestNextUpService.getRemovedFromNextUp(user.id) + val series = mutableListOf<RemovedItem>() + removed.keys.chunked(50).forEach { ids -> + val results = + api.itemsApi + .getItems( + userId = user.id, + ids = ids, + ).toBaseItems(api, false) + results.forEach { + val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY) + series.add(RemovedItem(it, imageUrl, removed[it.id]!!)) + } + } + _state.update { it.copy(loading = DataLoadingState.Success(series)) } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching removed series") + _state.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + } + + fun remove(item: RemovedItem) { + serverRepository.currentUser?.let { user -> + viewModelScope.launchDefault { + mutex.withLock { + _state.update { it.copy(removedEnabled = false) } + try { + latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id) + val newItems = + (_state.value.loading as? DataLoadingState.Success<List<RemovedItem>>) + ?.data + ?.toMutableList() + ?.apply { + removeIf { it.series.id == item.series.id } + } + val loading = + if (newItems != null) { + DataLoadingState.Success(newItems) + } else { + DataLoadingState.Error("Error occurred") + } + _state.update { + it.copy( + loading = loading, + removedEnabled = true, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error removing %s from removed next up", item.series.id) + } finally { + _state.update { it.copy(removedEnabled = true) } + } + } + } + } + } + } + +@Stable +data class RemovedItem( + val series: BaseItem, + val imageUrl: String?, + val datetime: LocalDateTime, +) + +data class RemovedNextUpState( + val loading: DataLoadingState<List<RemovedItem>> = DataLoadingState.Pending, + val removedEnabled: Boolean = true, +) + +@Composable +fun RemovedNextUpContent( + modifier: Modifier = Modifier, + viewModel: RemovedNextUpContentViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + Column( + modifier = modifier, + ) { + Text( + text = "Removed from next up", + style = MaterialTheme.typography.displaySmall, + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier.fillMaxWidth()) + } + + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier.fillMaxWidth()) + } + + is DataLoadingState.Success<List<RemovedItem>> -> { + if (s.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LazyColumn( + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) { + items(s.data, key = { it.series.id }) { item -> + RemovedListItem( + item = item, + removedEnabled = state.removedEnabled, + onClickRemove = + remember { + { viewModel.remove(item) } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } +} + +@Composable +fun RemovedListItem( + item: RemovedItem, + removedEnabled: Boolean, + onClickRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + ListItem( + selected = false, + onClick = {}, + leadingContent = { + AsyncImage( + model = item.imageUrl, + contentDescription = item.series.title, + modifier = Modifier.height(80.dp), + ) + }, + headlineContent = { + Text( + text = item.series.title ?: item.series.id.toString(), + ) + }, + supportingContent = { + Text( + text = formatDateTime(item.datetime), + ) + }, + scale = ListItemDefaults.scale(focusedScale = 1f), + modifier = Modifier.weight(1f), + ) + Button( + onClick = onClickRemove, + enabled = removedEnabled, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } +} 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/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 0e3d5a43..0cf42622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -1,62 +1,32 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSerializable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavBackStackSerializer -import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.DrawerValue -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.rememberDrawerState -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.transitionFactory import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.BackdropStyle 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.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.launchIO import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) -private const val TOP_SCRIM_ALPHA = 0.55f -private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height +const val TOP_SCRIM_ALPHA = 0.55f +const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height @HiltViewModel class ApplicationContentViewModel @@ -78,160 +48,23 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, - startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val backStack: MutableList<NavKey> = - rememberSerializable( - server, - user, - serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), - ) { - NavBackStack(startDestination) - } - navigationManager.backStack = backStack - val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() - val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { - val baseBackgroundColor = MaterialTheme.colorScheme.background - if (backdrop.hasColors && - (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) - ) { - val animPrimary by animateColorAsState( - backdrop.primaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_primary", - ) - val animSecondary by animateColorAsState( - backdrop.secondaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_secondary", - ) - val animTertiary by animateColorAsState( - backdrop.tertiaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_tertiary", - ) - Box( - modifier = - Modifier - .fillMaxSize() - .drawBehind { - drawRect(color = baseBackgroundColor) - // Top Left (Vibrant/Muted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animSecondary, Color.Transparent), - center = Offset(0f, 0f), - radius = size.width * 0.8f, - ), - ) - // Bottom Right (DarkVibrant/DarkMuted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animPrimary, Color.Transparent), - center = Offset(size.width, size.height), - radius = size.width * 0.8f, - ), - ) - // Bottom Left (Dark / Bridge) - drawRect( - brush = - Brush.radialGradient( - colors = - listOf( - baseBackgroundColor, - Color.Transparent, - ), - center = Offset(0f, size.height), - radius = size.width * 0.8f, - ), - ) - // Top Right (Under Image - Vibrant/Bright) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animTertiary, Color.Transparent), - center = Offset(size.width, 0f), - radius = size.width * 0.8f, - ), - ) - }, - ) - } - if (backdropStyle != BackdropStyle.BACKDROP_NONE) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(backdrop.imageUrl) - .transitionFactory(CrossFadeFactory(800.milliseconds)) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - Modifier - .align(Alignment.TopEnd) - .fillMaxHeight(.7f) - .fillMaxWidth(.7f) - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - if (drawerState.isOpen) { - drawRect( - brush = SolidColor(Color.Black), - alpha = .75f, - ) - } - // Subtle top scrim for system UI readability (clock, tabs) - if (enableTopScrim) { - drawRect( - brush = - Brush.verticalGradient( - colorStops = - arrayOf( - 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), - TOP_SCRIM_END_FRACTION to Color.Transparent, - ), - ), - blendMode = BlendMode.Multiply, - ) - } - drawRect( - brush = - Brush.horizontalGradient( - colors = listOf(Color.Transparent, Color.Black), - startX = 0f, - endX = size.width * 0.6f, - ), - blendMode = BlendMode.DstIn, - ) - drawRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Black, Color.Transparent), - startY = 0f, - endY = size.height, - ), - blendMode = BlendMode.DstIn, - ) - }, - ) - } - } + val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + Backdrop( + drawerIsOpen = drawerState.isOpen, + backdropStyle = backdropStyle, + enableTopScrim = enableTopScrim, + viewModel = viewModel, + ) val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, 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 new file mode 100644 index 00000000..188ad347 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt @@ -0,0 +1,217 @@ +@file:OptIn(ExperimentalCoilApi::class) + +package com.github.damontecres.wholphin.ui.nav + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +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 +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.preferences.BackdropStyle +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService] + */ +@Composable +fun Backdrop( + drawerIsOpen: Boolean, + backdropStyle: BackdropStyle, + viewModel: ApplicationContentViewModel = hiltViewModel(), + modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, + useExistingImageAsPlaceholder: Boolean = false, + crossfadeDuration: Duration = 800.milliseconds, +) { + val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() + Backdrop( + backdrop = backdrop, + drawerIsOpen = drawerIsOpen, + backdropStyle = backdropStyle, + modifier = modifier, + enableTopScrim = enableTopScrim, + useExistingImageAsPlaceholder = useExistingImageAsPlaceholder, + crossfadeDuration = crossfadeDuration, + ) +} + +/** + * Shows the current backdrop images provided by the [BackdropResult] + */ +@Composable +fun Backdrop( + backdrop: BackdropResult, + drawerIsOpen: Boolean, + backdropStyle: BackdropStyle, + modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, + 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) + ) { + val animPrimary by animateColorAsState( + backdrop.primaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_primary", + ) + val animSecondary by animateColorAsState( + backdrop.secondaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_secondary", + ) + val animTertiary by animateColorAsState( + backdrop.tertiaryColor, + animationSpec = tween(1250), + label = "dynamic_backdrop_tertiary", + ) + Box( + modifier = + modifier + .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(start, 0f), + radius = size.width * 0.8f, + ), + ) + // Bottom Right (DarkVibrant/DarkMuted) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animPrimary, Color.Transparent), + center = Offset(end, size.height), + radius = size.width * 0.8f, + ), + ) + // Bottom Left (Dark / Bridge) + drawRect( + brush = + Brush.radialGradient( + colors = + listOf( + baseBackgroundColor, + Color.Transparent, + ), + center = Offset(start, size.height), + radius = size.width * 0.8f, + ), + ) + // Top Right (Under Image - Vibrant/Bright) + drawRect( + brush = + Brush.radialGradient( + colors = listOf(animTertiary, Color.Transparent), + center = Offset(end, 0f), + radius = size.width * 0.8f, + ), + ) + }, + ) + } + if (backdropStyle != BackdropStyle.BACKDROP_NONE) { + Box( + modifier = modifier.fillMaxSize(), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(backdrop.imageUrl) + .useExistingImageAsPlaceholder(useExistingImageAsPlaceholder) + .transitionFactory(CrossFadeFactory(crossfadeDuration)) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + val start = if (isRtl) size.width else 0f + drawContent() + if (drawerIsOpen) { + drawRect( + brush = SolidColor(Color.Black), + alpha = .75f, + ) + } + // Subtle top scrim for system UI readability (clock, tabs) + if (enableTopScrim) { + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + drawRect( + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = start, + endX = size.width * 0.6f, + ), + blendMode = BlendMode.DstIn, + ) + drawRect( + brush = + Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = 0f, + endY = size.height, + ), + 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 0d16f36a..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,16 +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.ItemPlayback +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 @@ -28,11 +35,17 @@ 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, ) : Destination() + @Serializable + data object HomeSettings : Destination(true) + @Serializable data class Settings( val screen: PreferenceScreenOption, @@ -44,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( @@ -60,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) } @@ -73,8 +91,9 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, - val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, + val backend: PlayerBackend? = null, + val shuffle: Boolean = false, ) : Destination(true) { constructor(item: BaseItem) : this(item.id, item.resumeMs) } @@ -94,20 +113,31 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, + 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, @@ -124,8 +154,21 @@ 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 + data object NowPlaying : Destination(true) + @Serializable data object UpdateApp : Destination(true) 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 c1e70880..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 @@ -6,36 +6,49 @@ import androidx.compose.ui.Modifier import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.PlayerBackend 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.detail.CollectionFolderBoxSet +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 +import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist 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 import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails 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 @@ -56,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, @@ -63,14 +80,42 @@ fun DestinationContent( ) } - is Destination.PlaybackList, - is Destination.Playback, - -> { - PlaybackPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) + is Destination.HomeSettings -> { + HomeSettingsPage(preferences, modifier) + } + + is Destination.Playback -> { + val backend = + destination.backend ?: preferences.appPreferences.playbackPreferences.playerBackend + if (backend == PlayerBackend.EXTERNAL_PLAYER) { + PlayExternalPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } else { + PlaybackPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + } + + is Destination.PlaybackList -> { + if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) { + PlayExternalPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } else { + PlaybackPage( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } } is Destination.Settings -> { @@ -89,6 +134,10 @@ fun DestinationContent( ) } + Destination.UserAppPreferences -> { + UserProfilePreferencesPage(modifier) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, @@ -116,7 +165,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, @@ -135,11 +186,9 @@ fun DestinationContent( BaseItemKind.BOX_SET -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderBoxSet( + CollectionDetails( preferences = preferences, itemId = destination.itemId, - recursive = false, - playEnabled = true, modifier = modifier, ) } @@ -147,6 +196,7 @@ fun DestinationContent( BaseItemKind.PLAYLIST -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } PlaylistDetails( + preferences = preferences, destination = destination, modifier = modifier, ) @@ -202,14 +252,42 @@ fun DestinationContent( CollectionFolderPhotoAlbum( preferences = preferences, itemId = destination.itemId, - recursive = true, + recursive = false, + modifier = modifier, + ) + } + + BaseItemKind.MUSIC_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + AlbumDetailsPage( + preferences = preferences, + itemId = destination.itemId, + initialSongId = destination.initialSongId, + modifier = modifier, + ) + } + + BaseItemKind.MUSIC_ARTIST -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + ArtistDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + + 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}") + Text("Unsupported item type: ${destination.type}", modifier) } } } @@ -223,7 +301,22 @@ fun DestinationContent( recursive = destination.recursive, usePosters = true, playEnabled = true, // TODO only genres use this currently, so might need to change in future - filterOptions = DefaultForGenresFilterOptions, + filterOptions = + when (destination.parentType) { + BaseItemKind.GENRE -> DefaultForGenresFilterOptions + 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, ) } @@ -238,9 +331,10 @@ fun DestinationContent( ) } - is Destination.ItemGrid -> { + is Destination.ItemGrid<*> -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( + preferences, destination, modifier, ) @@ -260,6 +354,10 @@ fun DestinationContent( ) } + Destination.NowPlaying -> { + NowPlayingPage(modifier) + } + Destination.UpdateApp -> { InstallUpdatePage(preferences, modifier) } @@ -268,9 +366,10 @@ fun DestinationContent( LicenseInfo(modifier) } - Destination.Search -> { + is Destination.Search -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( + initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) @@ -281,6 +380,7 @@ fun DestinationContent( } Destination.Discover -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPage( preferences = preferences, modifier = modifier, @@ -309,6 +409,7 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, + startIndex = destination.startIndex, modifier = modifier, ) } @@ -321,6 +422,16 @@ fun DestinationContent( } } } + + is Destination.DiscoverMoreResult -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + DiscoverRequestGrid( + destination = destination, + showTitle = true, + positionCallback = { _, _ -> }, + modifier = modifier, + ) + } } } @@ -379,11 +490,27 @@ fun CollectionFolder( ) } + CollectionType.MUSIC -> { + CollectionFolderMusic( + preferences, + destination, + modifier, + ) + } + CollectionType.HOMEVIDEOS, - CollectionType.MUSICVIDEOS, - CollectionType.MUSIC, - CollectionType.BOOKS, CollectionType.PHOTOS, + -> { + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = recursiveOverride ?: false, + modifier = modifier, + ) + } + + CollectionType.MUSICVIDEOS, + CollectionType.BOOKS, -> { CollectionFolderGeneric( preferences, 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 6eac3206..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 @@ -2,9 +2,12 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -24,13 +27,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable 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 @@ -51,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 @@ -64,34 +67,32 @@ 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.NavDrawerItemRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors 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.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository 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.launchIO +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.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -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.imageApi import org.jellyfin.sdk.model.api.CollectionType @@ -104,86 +105,21 @@ class NavDrawerViewModel @Inject constructor( private val api: ApiClient, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, - private val seerrServerRepository: SeerrServerRepository, + private val musicService: MusicService, ) : ViewModel() { - val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) - val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) + val serviceState = navDrawerService.state - val selectedIndex = MutableLiveData(-1) - val showMore = MutableLiveData(false) - - init { - seerrServerRepository.active - .onEach { - init() - }.launchIn(viewModelScope) - } - - fun init() { - viewModelScope.launchIO { - val all = navDrawerItemRepository.getNavDrawerItems() - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } - - withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries - } - val asDestinations = - ( - libraries + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + moreLibraries - ).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 - } - } - - 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) { - -1 - } else if (key is Destination.Search) { - -2 - } else { - val idx = asDestinations.indexOf(key) - if (idx >= 0) { - idx - } else { - null - } - } - Timber.v("Found $index => $key") - if (index != null) { - selectedIndex.setValueOnMain(index) - break - } - } - } - } - } + 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) @@ -193,7 +129,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!showMore.value!!) + setShowMore(!state.value.moreExpanded) } NavDrawerItem.Discover -> { @@ -211,16 +147,78 @@ class NavDrawerViewModel } fun setIndex(index: Int) { - selectedIndex.value = index + _state.update { it.copy(selectedIndex = index) } } fun setShowMore(value: Boolean) { - showMore.value = value + _state.update { it.copy(moreExpanded = value) } } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) + + /** + * Determine which nav drawer item should be highlighted as currently selected + */ + fun updateSelectedIndex() { + viewModelScope.launchDefault { + val asDestinations = + 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] + 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 { + null + } + } + Timber.v("Found $index => $key") + if (index != null) { + _state.update { it.copy(selectedIndex = index) } + break + } + } + } + } + + fun navigateToSetup(userList: SetupDestination) { + viewModelScope.launchDefault { + musicService.stop() + setupNavigationManager.navigateTo(userList) + } + } } +data class NavDrawerState( + val selectedIndex: Int = -1, + val moreExpanded: Boolean = false, +) + +/** + * An item that can be shown in the nav drawer + * + * Some are built-in such as Favorites. Others are created dynamically for libraries. + */ sealed interface NavDrawerItem { val id: String @@ -248,17 +246,28 @@ sealed interface NavDrawerItem { } } +/** + * A server provided nav drawer item, typically a library + */ data class ServerNavDrawerItem( val itemId: UUID, val name: String, val destination: Destination, val type: CollectionType, ) : NavDrawerItem { - override val id: String = "s_" + itemId.toServerString() + override val id: String = getId(itemId) override fun name(context: Context): String = name + + companion object { + fun getId(itemId: UUID) = "s_" + itemId.toServerString() + } } +private const val HOME_INDEX = -1 +private const val SEARCH_INDEX = -2 +private const val NOW_PLAYING_INDEX = -3 + /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -278,6 +287,7 @@ fun NavDrawer( key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { + LaunchedEffect(Unit) { viewModel.updateSelectedIndex() } val scope = rememberCoroutineScope() val context = LocalContext.current val density = LocalDensity.current @@ -289,15 +299,13 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } - val moreLibraries by viewModel.moreLibraries.observeAsState(listOf()) - val libraries by viewModel.libraries.observeAsState(listOf()) - LaunchedEffect(Unit) { viewModel.init() } + val serviceState by viewModel.serviceState.collectAsState() + val state by viewModel.state.collectAsState() + val moreExpanded = state.moreExpanded + // A negative index is a built-in page, >=0 is a library + val selectedIndex = state.selectedIndex - val showMore by viewModel.showMore.observeAsState(false) - // A negative index is a built in page, >=0 is a library - val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - - BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) } @@ -343,12 +351,37 @@ fun NavDrawer( drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setupNavigationManager.navigateTo( + viewModel.navigateToSetup( SetupDestination.UserList(server), ) }, modifier = Modifier, ) + AnimatedVisibility( + 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 = serviceState.nowPlayingTitle, + icon = Icons.Default.PlayArrow, + selected = selectedIndex == NOW_PLAYING_INDEX, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.setIndex(NOW_PLAYING_INDEX) + viewModel.navigationManager.navigateTo(Destination.NowPlaying) + }, + modifier = + Modifier + .ifElse( + selectedIndex == NOW_PLAYING_INDEX, + Modifier.focusRequester(focusRequester), + ), + ) + } LazyColumn( state = navDrawerListState, contentPadding = PaddingValues(0.dp), @@ -372,18 +405,18 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, - selected = selectedIndex == -2, + selected = selectedIndex == SEARCH_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-2) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search) + viewModel.setIndex(SEARCH_INDEX) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) }, modifier = Modifier .focusRequester(searchFocusRequester) .ifElse( - selectedIndex == -2, + selectedIndex == SEARCH_INDEX, Modifier.focusRequester(focusRequester), ), ) @@ -393,13 +426,14 @@ fun NavDrawer( IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, - selected = selectedIndex == -1, + selected = selectedIndex == HOME_INDEX, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(-1) + viewModel.setIndex(HOME_INDEX) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() + onClearBackdrop.invoke() } else { viewModel.navigationManager.goToHome() } @@ -407,17 +441,17 @@ fun NavDrawer( modifier = Modifier .ifElse( - selectedIndex == -1, + selectedIndex == HOME_INDEX, Modifier.focusRequester(focusRequester), ), ) } - itemsIndexed(libraries) { index, it -> + itemsIndexed(serviceState.items) { index, it -> val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, selected = selectedIndex == index, - moreExpanded = showMore, + moreExpanded = moreExpanded, drawerOpen = isOpen, interactionSource = interactionSource, onClick = { @@ -431,19 +465,47 @@ fun NavDrawer( ), ) } - if (showMore) { - itemsIndexed(moreLibraries) { index, it -> - val adjustedIndex = (index + libraries.size + 1) + if (serviceState.moreItems.isNotEmpty()) { + item { + val index = serviceState.items.size + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = NavDrawerItem.More, + selected = false, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, NavDrawerItem.More) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + if (moreExpanded) { + itemsIndexed(serviceState.moreItems) { index, it -> + val adjustedIndex = + remember(serviceState) { (index + serviceState.items.size) } val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, selected = selectedIndex == adjustedIndex, - moreExpanded = showMore, + moreExpanded = moreExpanded, drawerOpen = isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, containerColor = if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + MaterialTheme.colorScheme.surface.copy(alpha = .5f) } else { Color.Unspecified }, @@ -512,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -534,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = user.name ?: user.id.toString(), maxLines = 1, ) @@ -578,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = text, maxLines = 1, ) @@ -672,7 +735,7 @@ fun NavigationDrawerScope.NavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = library.name(context), maxLines = 1, ) @@ -711,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/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt index 30b9d1d6..0cca40c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt @@ -57,6 +57,11 @@ import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.rememberDrawerState +/** + * This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer]. + * + * It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation. + */ @Composable fun ModalNavigationDrawer( drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt deleted file mode 100644 index 7fc97366..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.Player -import androidx.media3.common.Player.Listener -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn - -/** - * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback - * - * This will clean up the listener when disposed - */ -@Composable -fun AmbientPlayerListener(player: Player) { - val context = LocalContext.current - DisposableEffect(player) { - val listener = - object : Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - context.findActivity()?.keepScreenOn(isPlaying) - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - context.findActivity()?.keepScreenOn(false) - } - } -} 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 2b7d61bc..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 @@ -1,8 +1,21 @@ package com.github.damontecres.wholphin.ui.playback +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 +/** + * Metadata about the currently playing media + * + * @see CurrentPlayback + */ data class CurrentMediaInfo( val sourceId: String?, val videoStream: SimpleVideoStream?, @@ -15,3 +28,56 @@ data class CurrentMediaInfo( val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } + +/** + * 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() + .setTitle(title) + .setSubtitle(subtitle) + .setReleaseYear(data.productionYear) + .setDescription(data.overview) + .setArtworkUri(imageUrl?.toUri()) + .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds) + .setMediaType( + when (type) { + BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE + BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW + BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO + BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL + else -> MediaMetadata.MEDIA_TYPE_VIDEO + }, + ).build() 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 9c62f17b..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,13 +1,21 @@ 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 +/** + * Information about how the current media is being played such transcoding and decoder info + * + * @see CurrentMediaInfo + */ data class CurrentPlayback( val item: BaseItem, val tracks: List<TrackSupport>, @@ -20,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 b3813ed5..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,72 +29,60 @@ 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 @Composable fun DownloadSubtitlesContent( - state: SubtitleSearch, + state: SubtitleSearchStatus, language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, - onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { when (val s = state) { - SubtitleSearch.Searching -> { - Wrapper { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + SubtitleSearchStatus.Inactive -> {} + + SubtitleSearchStatus.Searching -> { + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } - SubtitleSearch.Downloading -> { - Wrapper { - Text( - text = stringResource(R.string.downloading), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + SubtitleSearchStatus.Downloading -> { + Text( + text = stringResource(R.string.downloading), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) } - is SubtitleSearch.Error -> { - Wrapper { ErrorMessage(null, s.ex, modifier) } + is SubtitleSearchStatus.Error -> { + ErrorMessage(null, s.ex, modifier) } - is SubtitleSearch.Success -> { - val dialogItems = convertRemoteSubtitles(s.options, onClickDownload) + is SubtitleSearchStatus.Success -> { 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 = "Search & download subtitles", + text = stringResource(R.string.search_and_download_subtitles), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -113,7 +93,7 @@ fun DownloadSubtitlesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Language", + text = stringResource(R.string.language), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -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 = "No remote subtitles were found", + 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/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt index 94a41aa1..30165f58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -1,23 +1,23 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.annotation.OptIn import androidx.media3.common.ForwardingSimpleBasePlayer import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.seekBack import com.google.common.util.concurrent.ListenableFuture import timber.log.Timber +@OptIn(UnstableApi::class) class MediaSessionPlayer( player: Player, - private val controllerViewState: ControllerViewState, private val playbackPreferences: PlaybackPreferences, ) : ForwardingSimpleBasePlayer(player) { override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") - if (!playWhenReady && player.isPlaying) { - controllerViewState.showControls() - } else if (playWhenReady) { + if (playWhenReady) { playbackPreferences.skipBackOnResume?.let { player.seekBack(it) } 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 new file mode 100644 index 00000000..b17036b8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -0,0 +1,451 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.viewModelScope +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 +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.LoadingState +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.update +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.model.api.MediaStream +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.PlaybackOrder +import org.jellyfin.sdk.model.api.PlaybackStartInfo +import org.jellyfin.sdk.model.api.PlaybackStopInfo +import org.jellyfin.sdk.model.api.RepeatMode +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class PlayExternalViewModel + @Inject + constructor( + private val savedStateHandle: SavedStateHandle, + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val itemPlaybackDao: ItemPlaybackDao, + private val playlistCreator: PlaylistCreator, + private val streamChoiceService: StreamChoiceService, + private val navigationManager: NavigationManager, + private val userPreferencesService: UserPreferencesService, + ) : ViewModel() { + val launched = savedStateHandle.getMutableStateFlow("launched", false) + val state = MutableStateFlow(PlayExternalState()) + + fun init(destination: Destination) { + Timber.v("init called: %s", destination) + state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchDefault { + val prefs = userPreferencesService.getCurrent() + val positionMs: Long + val itemId = + when (val d = destination) { + is Destination.Playback -> { + positionMs = d.positionMs + d.itemId + } + + is Destination.PlaybackList -> { + positionMs = 0 + d.itemId + } + + else -> { + throw IllegalArgumentException("Destination not supported: $destination") + } + } + try { + val queriedItem = api.userLibraryApi.getItem(itemId).content + val playlistItem = + if (queriedItem.type.playable) { + PlaylistItem.Media(BaseItem(queriedItem)) + } else if (destination is Destination.PlaybackList) { + val playlistResult = + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + state.update { + it.copy( + loading = LoadingState.Error(r.message, r.ex), + ) + } + return@launchDefault + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return@launchDefault + } + r.playlist.items.first() + } + } + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") + } + val playbackConfig = + serverRepository.currentUser?.let { user -> + itemPlaybackDao.getItem(user, playlistItem.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null + } + } + } + val item = + when (playlistItem) { + is PlaylistItem.Intro -> playlistItem.item + is PlaylistItem.Media -> playlistItem.item + } + val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) + if (mediaSource == null) { + Timber.w("Media source is null") + return@launchDefault + } + savedStateHandle[KEY_ID] = playlistItem.id + savedStateHandle[KEY_MEDIA_ID] = mediaSource.id + val subtitleIndex = + streamChoiceService + .chooseSubtitleStream( + item = item.data, + source = mediaSource, + audioStream = null, + itemPlayback = playbackConfig, + stc = subtitleStc, + prefs = prefs, + )?.stream + ?.index + val externalSubtitles = + mediaSource.mediaStreams + ?.filter { it.isExternal } + ?.sortedWith(compareBy<MediaStream> { it.index == subtitleIndex }.thenBy { it.isDefault }) + .orEmpty() + val subtitleUrls = + externalSubtitles.map { + val format = it.path?.let { File(it).extension } ?: "srt" + api.subtitleApi + .getSubtitleUrl( + routeItemId = itemId, + routeMediaSourceId = mediaSource.id!!, + routeIndex = it.index, + routeFormat = format, + ).toUri() + } + + val uri = + api.videosApi + .getVideoStreamUrl( + itemId = item.id, + mediaSourceId = mediaSource.id, + static = true, + ).toUri() + val playerId = prefs.appPreferences.playbackPreferences.externalPlayer + // Make sure player is available, user could have uninstalled it + val foundPlayer = + getExternalPlayers(context).firstOrNull { it.identifier == playerId } != null + val component = + if (playerId.isNotNullOrBlank() && foundPlayer) { + ComponentName.unflattenFromString(playerId) + } else { + null + } + Timber.v("playerId=%s, component=%s", playerId, component) + val title = "${item.title} ${item.subtitleLong}" + val intent = + Intent(Intent.ACTION_VIEW).apply { + setComponent(component) + setDataAndTypeAndNormalize(uri, "video/*") + putExtra("title", title) + putExtra("position", positionMs.toInt()) + + // MX/mpv + putExtra("return_result", true) + putExtra("secure_uri", true) + putExtra("subs", subtitleUrls.toTypedArray()) + putExtra( + "subs.name", + externalSubtitles + .map { it.displayTitle ?: it.index.toString() } + .toTypedArray(), + ) + if (subtitleIndex != null) { + externalSubtitles + .indexOfFirstOrNull { it.index == subtitleIndex } + ?.let { + putExtra("subs.enable", arrayOf(subtitleUrls[it])) + } + } + + // 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()) + } + mediaSource.runTimeTicks?.ticks?.inWholeMilliseconds?.let { + putExtra("extra_duration", it) + } + + // Vimu - https://vimu.tv/player-api/ + putExtra("startfrom", positionMs.toInt()) + putExtra("forceresume", false) + putExtra("forcename", title) + externalSubtitles + .indexOfFirstOrNull { it.index == subtitleIndex && it.codec == "srt" } + ?.let { + putExtra("forcedsrt", subtitleUrls[it]) + } + } + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = false, + itemId = itemId, + isPaused = false, + playMethod = PlayMethod.DIRECT_PLAY, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + ), + ) + state.update { + PlayExternalState( + loading = LoadingState.Success, + intent = intent, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error for destination %s", destination) + state.update { + it.copy(loading = LoadingState.Error(ex)) + } + } + } + } + + fun onResult(result: ActivityResult) { + viewModelScope.launchDefault { + val itemId = savedStateHandle.get<UUID?>(KEY_ID) + try { + val mediaSourceId = savedStateHandle.get<String?>(KEY_MEDIA_ID) + if (itemId == null) { + Timber.w("itemId is null") + return@launchDefault + } + Timber.v( + "Result: result=%s, action=%s, itemId=%s", + result.resultCode, + result.data?.action, + itemId, + ) + if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || + // Vimu return 1 for video completion + (result.data?.action == "net.gtvbox.videoplayer.result" && result.resultCode == 1) + ) { + val position: Long? + val data = result.data + when (data?.action) { + // VLC: https://wiki.videolan.org/Android_Player_Intents/ + "org.videolan.vlc.player.result" -> { + position = + data + .getLongExtra("extra_position", Long.MIN_VALUE) + .takeIf { it > 0 } + } + + // mpv-android: https://mpv-android.github.io/mpv-android/intent.html + "is.xyz.mpv.MPVActivity.result", + // MX player: https://mx.j2inter.com/api + "com.mxtech.intent.result.VIEW", + // VIMU: https://vimu.tv/player-api/ + "net.gtvbox.videoplayer.result", + -> { + position = + data + .getIntExtra("position", Int.MIN_VALUE) + .toLong() + .takeIf { it >= 0 } + } + + else -> { + // Unsupported app + val posInt = + data + ?.getIntExtra("position", Int.MIN_VALUE) + ?.takeIf { it >= 0 } + ?.toLong() + position = + posInt ?: data + ?.getLongExtra("position", -1L) + ?.takeIf { it >= 0 } + } + } + 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?.takeIf { it >= 0 }, + failed = false, + ), + ) + } + } else { + Timber.w( + "Activity result: %s, action=%s", + result.resultCode, + result.data?.action, + ) + showToast(context, "Unknown result from external player") + } + navigationManager.goBack() + state.update { PlayExternalState() } + launched.update { false } + } catch (_: CancellationException) { + } catch (ex: Exception) { + Timber.e(ex, "Error during external playback of %s", itemId) + state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun reportException(ex: Exception) { + Timber.e(ex, "Error launching activity") + state.update { it.copy(loading = LoadingState.Error(ex)) } + } + + companion object { + private const val KEY_ID = "itemId" + private const val KEY_MEDIA_ID = "mediaId" + } + } + +data class PlayExternalState( + val loading: LoadingState = LoadingState.Pending, + val intent: Intent = Intent(), +) + +@Composable +fun PlayExternalPage( + preferences: UserPreferences, + destination: Destination, + modifier: Modifier = Modifier, + viewModel: PlayExternalViewModel = + hiltViewModel( + viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity, + ), +) { + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = viewModel::onResult, + ) + + val state by viewModel.state.collectAsState() + val launched by viewModel.launched.collectAsState() + LaunchedEffect(Unit) { + if (!launched) { + viewModel.init(destination) + } + } + + when (val l = state.loading) { + LoadingState.Pending -> { + LoadingPage(modifier, false) + } + + LoadingState.Loading, + -> { + LoadingPage(modifier) + } + + is LoadingState.Error -> { + ErrorMessage(l, modifier) + } + + LoadingState.Success -> { + LoadingPage(modifier) + if (!launched) { + LifecycleStartEffect(Unit) { + Timber.i("Launching external playback") + viewModel.launched.update { true } + try { + launcher.launch(state.intent) + } catch (ex: Exception) { + viewModel.reportException(ex) + } + onStopOrDispose { } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,20 +1,22 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") val playbackScaleOptions = mapOf( - ContentScale.Fit to "Fit", - ContentScale.None to "None", - ContentScale.Crop to "Crop", + ContentScale.Fit to R.string.content_scale_fit, + ContentScale.None to R.string.none, + ContentScale.Crop to R.string.content_scale_crop, // ContentScale.Inside to "Inside", - ContentScale.FillBounds to "Fill", - ContentScale.FillWidth to "Fill Width", - ContentScale.FillHeight to "Fill Height", + ContentScale.FillBounds to R.string.content_scale_fill, + ContentScale.FillWidth to R.string.content_scale_fill_width, + ContentScale.FillHeight to R.string.content_scale_fill_height, ) val PrefContentScale.scale: ContentScale @@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } + +fun getTypeFor(collectionType: CollectionType): BaseItemKind? = + when (collectionType) { + CollectionType.UNKNOWN -> null + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + CollectionType.MUSIC -> BaseItemKind.AUDIO + CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO + CollectionType.TRAILERS -> BaseItemKind.TRAILER + CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO + CollectionType.BOXSETS -> BaseItemKind.BOX_SET + CollectionType.BOOKS -> BaseItemKind.BOOK + CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM + CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL + CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST + CollectionType.FOLDERS -> BaseItemKind.FOLDER + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt deleted file mode 100644 index ce31d865..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import android.content.Context -import android.hardware.display.DisplayManager -import android.view.Display -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ProvideTextStyle -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.byteRateSuffixes -import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.letNotEmpty -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import org.jellyfin.sdk.model.api.TranscodingInfo -import timber.log.Timber -import java.util.Locale -import kotlin.time.Duration.Companion.seconds - -@Composable -fun PlaybackDebugOverlay( - currentPlayback: CurrentPlayback?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val display = - remember(context) { - try { - val displayManager = - context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager - displayManager?.getDisplay(Display.DEFAULT_DISPLAY) - } catch (ex: Exception) { - Timber.e(ex) - null - } - } - val displayMode by produceState<String?>(null) { - while (isActive) { - value = - display?.mode?.let { - val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate) - "${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}" - } - delay(10.seconds) - } - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(48.dp), - modifier = Modifier.padding(start = 8.dp, top = 8.dp), - ) { - ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) { - SimpleTable( - buildList { - add("Backend:" to currentPlayback?.backend?.toString()) - add("Play method:" to currentPlayback?.playMethod?.serialName) - if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) { - add("Video Decoder:" to currentPlayback.videoDecoder) - add("Audio Decoder:" to currentPlayback.audioDecoder) - } - add("Display Mode: " to displayMode?.toString()) - }, - modifier = Modifier.weight(1f, fill = false), - ) - currentPlayback?.transcodeInfo?.let { - TranscodeInfo(it, Modifier.weight(2f)) - } - } - } - currentPlayback?.tracks?.letNotEmpty { - PlaybackTrackInfo( - trackSupport = it, - ) - } - } -} - -@Composable -fun TranscodeInfo( - info: TranscodingInfo, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier, - ) { - SimpleTable( - listOf( - "Reason:" to info.transcodeReasons.joinToString(", "), - "HW Accel:" to info.hardwareAccelerationType?.toString(), - "Container:" to info.container, - "Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, - ), - ) - SimpleTable( - listOf( - "Video:" to "${info.videoCodec}, ${info.width}x${info.height}", - "Video Direct:" to info.isVideoDirect.toString(), - "Audio:" to "${info.audioCodec}, ch=${info.audioChannels}", - "Audio Direct:" to info.isAudioDirect.toString(), - ), - ) - } -} - -@Composable -fun SimpleTable( - rows: List<Pair<String, String?>>, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - rows.forEach { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = it.first, - modifier = Modifier.width(100.dp), - ) - Text( - text = it.second.toString(), - modifier = Modifier, - ) - } - } - } -} 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 04ff620a..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,15 +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 @@ -30,18 +42,27 @@ 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 { - MORE, + DEBUG, CAPTIONS, SETTINGS, AUDIO, PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, + BITRATE, } data class PlaybackSettings( @@ -54,8 +75,18 @@ data class PlaybackSettings( val contentScale: ContentScale, val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, + val playbackSpeedEnabled: Boolean, + val maxBitrate: Long, ) +/** + * Centralized UI component for displaying dialogs during playback + * + * Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the + * [PlaybackDialogType] determining which dialog is shown by this component. + * + * @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction + */ @Composable fun PlaybackDialog( enableSubtitleDelay: Boolean, @@ -68,29 +99,13 @@ 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.MORE -> { - val options = - buildList { - add( - BottomDialogItem( - data = 0, - headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), - supporting = null, - ), - ) - } - BottomDialog( - choices = options, - onDismissRequest = { - onDismissRequest.invoke() -// focusRequester.tryRequestFocus() - }, - onSelectChoice = { index, choice -> - onPlaybackActionClick.invoke(PlaybackAction.ShowDebug) - }, - gravity = Gravity.START, - ) + PlaybackDialogType.DEBUG -> { + throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG) } PlaybackDialogType.CAPTIONS -> { @@ -116,27 +131,19 @@ fun PlaybackDialog( onDismissRequest.invoke() onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) }, - gravity = Gravity.END, + gravity = rightGravity, ) } PlaybackDialogType.SETTINGS -> { - val currentAudio = - remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add( - BottomDialogItem( - data = PlaybackDialogType.AUDIO, - headline = stringResource(R.string.audio), - supporting = currentAudio?.displayTitle, - ), - ) add( BottomDialogItem( data = PlaybackDialogType.PLAYBACK_SPEED, headline = stringResource(R.string.playback_speed), supporting = settings.playbackSpeed.toString(), + enabled = settings.playbackSpeedEnabled, ), ) if (enableVideoScale) { @@ -144,7 +151,9 @@ fun PlaybackDialog( BottomDialogItem( data = PlaybackDialogType.VIDEO_SCALE, headline = stringResource(R.string.video_scale), - supporting = playbackScaleOptions[settings.contentScale], + supporting = + playbackScaleOptions[settings.contentScale] + ?.let { stringResource(it) }, ), ) } @@ -157,15 +166,33 @@ 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, + headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ), + ) } BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, onSelectChoice = { _, choice -> - onClickPlaybackDialogType(choice.data) + if (choice.data == PlaybackDialogType.DEBUG) { + onPlaybackActionClick.invoke(PlaybackAction.ShowDebug) + } else { + onClickPlaybackDialogType(choice.data) + } }, - gravity = Gravity.END, + gravity = leftGravity, ) } @@ -176,15 +203,11 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) }, - gravity = Gravity.END, + gravity = rightGravity, ) } @@ -203,15 +226,11 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, value -> onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, - gravity = Gravity.END, + gravity = leftGravity, ) } @@ -220,7 +239,7 @@ fun PlaybackDialog( playbackScaleOptions.map { (scale, name) -> BottomDialogItem( data = scale, - headline = name, + headline = stringResource(name), supporting = null, ) } @@ -230,15 +249,11 @@ fun PlaybackDialog( onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// delay(250L) -// settingsFocusRequester.tryRequestFocus() -// } }, onSelectChoice = { _, choice -> onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, - gravity = Gravity.END, + gravity = leftGravity, ) } @@ -267,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, + ) + } } } @@ -280,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, @@ -302,6 +358,7 @@ fun SubtitleChoiceBottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -311,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) }, @@ -325,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 { @@ -343,6 +416,9 @@ fun SubtitleChoiceBottomDialog( }, supportingContent = {}, ) + if (choices.isNotEmpty()) { + HorizontalDivider() + } } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } @@ -363,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, @@ -378,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 + }, ) } } @@ -393,6 +486,14 @@ fun StreamChoiceBottomDialog( gravity: Int, currentChoice: Int? = null, ) { + val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + if (currentChoice != null) { + LaunchedEffect(Unit) { + choices.indexOfFirstOrNull { it.index == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -443,6 +544,7 @@ fun StreamChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(focusRequesters[index]), ) } } 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 f10a0758..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 @@ -20,36 +21,49 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, + 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 + fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - var result = true if (!controlsEnabled) { - result = false + return false + } else if (handleHoldSkip(it)) { + return true } else if (it.type != KeyEventType.KeyUp) { - result = false - } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + return false + } + + var result = true + 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) - if (wasPlaying) { - controllerViewState.showControls() - } else { + if (!wasPlaying) { skipBackOnResume?.let { player.seekBack(it) } @@ -77,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 -> { @@ -111,4 +125,96 @@ class PlaybackKeyHandler( } return result } + + private fun handleHoldSkip(event: KeyEvent): Boolean { + if ( + controllerViewState.controlsVisible || + !skipWithLeftRight || + (!isSkipBack(event) && !isSkipForward(event)) + ) { + return false + } + + val isBack = isSkipBack(event) + return when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + setHandledByRepeat(isBack = isBack, handled = false) + return true + } + val multiplier = + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = normalizedDurationMs(), + ) + setHandledByRepeat(isBack = isBack, handled = true) + seekWithMultiplier(isBack = isBack, multiplier = multiplier) + } else { + setHandledByRepeat(isBack = isBack, handled = false) + } + true + } + + KeyEventType.KeyUp -> { + if (!handledByRepeat(isBack = isBack)) { + seekWithMultiplier(isBack = isBack, multiplier = 1) + } + setHandledByRepeat(isBack = isBack, handled = false) + true + } + + else -> { + false + } + } + } + + private fun seekWithMultiplier( + isBack: Boolean, + multiplier: Int, + ) { + if (isBack) { + val skipDuration = seekBack * multiplier + seekBy(-skipDuration) + } else { + val skipDuration = seekForward * multiplier + 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) + } + } + + private fun setHandledByRepeat( + isBack: Boolean, + handled: Boolean, + ) { + if (isBack) { + leftHandledByRepeat = handled + } else { + rightHandledByRepeat = handled + } + } + + private fun handledByRepeat(isBack: Boolean): Boolean = + if (isBack) { + leftHandledByRepeat + } else { + rightHandledByRepeat + } + + private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt deleted file mode 100644 index ac6aaeed..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ /dev/null @@ -1,610 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.animation.AnimatedVisibility -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.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.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.lazy.LazyRow -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.mutableLongStateOf -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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -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.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.media3.common.Player -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.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.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.TimeFormatter -import com.github.damontecres.wholphin.ui.cards.ChapterCard -import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.TimeDisplay -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaSegmentDto -import org.jellyfin.sdk.model.api.TrickplayInfo -import java.time.LocalTime -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -private val titleTextSize = 28.sp -private val subtitleTextSize = 18.sp - -/** - * The overlay during playback showing controls, seek preview image, debug info, etc - */ -@Composable -fun PlaybackOverlay( - item: BaseItem?, - chapters: List<Chapter>, - playerControls: Player, - controllerViewState: ControllerViewState, - showPlay: Boolean, - showClock: Boolean, - previousEnabled: Boolean, - nextEnabled: Boolean, - seekEnabled: Boolean, - seekBack: Duration, - skipBackOnResume: Duration?, - seekForward: Duration, - onPlaybackActionClick: (PlaybackAction) -> Unit, - onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - onSeekBarChange: (Long) -> Unit, - showDebugInfo: Boolean, - currentPlayback: CurrentPlayback?, - currentSegment: MediaSegmentDto?, - modifier: Modifier = Modifier, - trickplayInfo: TrickplayInfo? = null, - trickplayUrlFor: (Int) -> String? = { null }, - playlist: Playlist = Playlist(listOf(), 0), - onClickPlaylist: (BaseItem) -> Unit = {}, - seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() - // Will be used for preview/trick play images - var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } - var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() - - val chapterInteractionSources = - remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } - - val density = LocalDensity.current - - val titleHeight = - remember { - if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp - } - val subtitleHeight = - remember { - if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp - } - - // This will be calculated after composition - var controllerHeight by remember { mutableStateOf(0.dp) } - var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } - - // Background scrim for OSD readability - val scrimBrush = - remember { - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.5f), - Color.Black.copy(alpha = 0.80f), - ), - ) - } - - Box( - modifier = modifier, - contentAlignment = Alignment.BottomCenter, - ) { - AnimatedVisibility( - visible = controllerViewState.controlsVisible, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.matchParentSize(), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(scrimBrush), - ) - } - - AnimatedVisibility( - state == OverlayViewState.CONTROLLER, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - val nextState = - if (chapters.isNotEmpty()) { - OverlayViewState.CHAPTERS - } else if (playlist.hasNext()) { - OverlayViewState.QUEUE - } else { - null - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(bottom = 8.dp) - .onGloballyPositioned { - controllerHeight = with(density) { it.size.height.toDp() } - }, - ) { - Controller( - title = item?.title, - subtitle = item?.subtitleLong, - playerControls = playerControls, - controllerViewState = controllerViewState, - showPlay = showPlay, - showClock = showClock, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBack = seekBack, - skipBackOnResume = skipBackOnResume, - seekForward = seekForward, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = onClickPlaybackDialogType, - onSeekProgress = { - onSeekBarChange(it) - seekProgressMs = it - }, - seekBarInteractionSource = seekBarInteractionSource, - nextState = nextState, - onNextStateFocus = { - nextState?.let { state = it } - }, - currentSegment = currentSegment, - modifier = - Modifier, - // Don't use key events because this control has vertical items so up/down is tough to manage - ) - when (nextState) { - OverlayViewState.CHAPTERS -> { - Text( - text = stringResource(R.string.chapters), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(start = 16.dp, top = 0.dp) - .onFocusChanged { - if (it.isFocused) state = nextState - }.focusable(), - ) - } - - OverlayViewState.QUEUE -> { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(start = 16.dp, top = 0.dp) - .onFocusChanged { - if (it.isFocused) state = nextState - }.focusable(), - ) - } - - else -> { - Spacer(Modifier.height(32.dp)) - } - } - } - } - AnimatedVisibility( - state == OverlayViewState.CHAPTERS, - enter = slideInVertically { it / 2 } + fadeIn(), - exit = slideOutVertically { it / 2 } + fadeOut(), - ) { - if (chapters.isNotEmpty()) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth() - .onPreviewKeyEvent { e -> - if (e.type == KeyEventType.KeyUp && isUp(e)) { - state = OverlayViewState.CONTROLLER - true - } else { - false - } - }, - ) { - Text( - text = stringResource(R.string.chapters), - style = MaterialTheme.typography.titleLarge, - ) - LazyRow( - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(focusRequester) - .onFocusChanged { - if (it.hasFocus) { - controllerViewState.pulseControls() - } - }, - ) { - itemsIndexed(chapters) { index, chapter -> - val interactionSource = chapterInteractionSources[index] - val isFocused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(isFocused) { - if (isFocused) controllerViewState.pulseControls() - } - ChapterCard( - name = chapter.name, - position = chapter.position, - imageUrl = chapter.imageUrl, - aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, - onClick = { - playerControls.seekTo(chapter.position.inWholeMilliseconds) - controllerViewState.hideControls() - }, - interactionSource = interactionSource, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } - if (playlist.hasNext()) { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(bottom = 8.dp) - .onFocusChanged { - if (it.isFocused) state = OverlayViewState.QUEUE - }.focusable(), - ) - } - } - } - } - AnimatedVisibility( - state == OverlayViewState.QUEUE, - enter = slideInVertically { it / 2 } + fadeIn(), - exit = slideOutVertically { it / 2 } + fadeOut(), - ) { - if (playlist.hasNext()) { - val items = remember { playlist.upcomingItems() } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth() - .onPreviewKeyEvent { e -> - if (e.type == KeyEventType.KeyUp && isUp(e)) { - if (chapters.isNotEmpty()) { - state = OverlayViewState.CHAPTERS - } else { - state = OverlayViewState.CONTROLLER - } - true - } else if (isDown(e)) { - true - } else { - false - } - }, - ) { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - ) - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(focusRequester) - .onFocusChanged { - if (it.hasFocus) { - controllerViewState.pulseControls() - } - }, - ) { - itemsIndexed(items) { index, item -> - val interactionSource = remember { MutableInteractionSource() } - val isFocused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(isFocused) { - if (isFocused) controllerViewState.pulseControls() - } - SeasonCard( - item = item, - onClick = { - onClickPlaylist.invoke(item) - controllerViewState.hideControls() - }, - onLongClick = {}, - imageHeight = 140.dp, - interactionSource = interactionSource, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } - } - } - } - - if (seekBarInteractionSource.collectIsFocusedAsState().value) { - LaunchedEffect(Unit) { - seekProgressPercent = - (playerControls.currentPosition.toFloat() / playerControls.duration) - } - } - - AnimatedVisibility( - seekProgressPercent >= 0 && seekBarFocused, - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxWidth(.95f), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .align(Alignment.BottomStart) - .offsetByPercent( - xPercentage = seekProgressPercent.coerceIn(0f, 1f), - ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), - ) { - 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, - ) - } - } - } - val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) - AnimatedVisibility( - !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, - modifier = - Modifier - .align(Alignment.TopStart), - ) { - AsyncImage( - model = logoImageUrl, - contentDescription = "Logo", - alignment = Alignment.TopStart, - modifier = - Modifier - .size(width = 240.dp, height = 120.dp) - .padding(16.dp), - ) - } - AnimatedVisibility( - !showDebugInfo && showClock && controllerViewState.controlsVisible, - modifier = - Modifier - .align(Alignment.TopEnd), - ) { - TimeDisplay() - } - AnimatedVisibility( - showDebugInfo && controllerViewState.controlsVisible, - modifier = - Modifier - .align(Alignment.TopStart), - ) { - PlaybackDebugOverlay( - currentPlayback = currentPlayback, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(8.dp) - .background(AppColors.TransparentBlack50), - ) - } - } -} - -/** - * The view state of the overlay - */ -enum class OverlayViewState { - CONTROLLER, - CHAPTERS, - QUEUE, -} - -/** - * A wrapper for the playback controls to show title and other information, plus the actual controls - */ -@Composable -fun Controller( - title: String?, - playerControls: Player, - controllerViewState: ControllerViewState, - showClock: Boolean, - showPlay: Boolean, - previousEnabled: Boolean, - nextEnabled: Boolean, - seekEnabled: Boolean, - seekBack: Duration, - skipBackOnResume: Duration?, - seekForward: Duration, - onPlaybackActionClick: (PlaybackAction) -> Unit, - onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - onSeekProgress: (Long) -> Unit, - nextState: OverlayViewState?, - currentSegment: MediaSegmentDto?, - modifier: Modifier = Modifier, - subtitle: String? = null, - seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onNextStateFocus: () -> Unit = {}, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(start = 16.dp), - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - fontSize = titleTextSize, - ) - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.End), - ) { - subtitle?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - fontSize = subtitleTextSize, - ) - } - if (showClock) { - var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(playerControls) { - while (isActive) { - val remaining = - (playerControls.duration - playerControls.currentPosition) - .div(playerControls.playbackParameters.speed) - .toLong() - .milliseconds - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) - delay(1.seconds) - } - } - Text( - text = "Ends $endTimeStr", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) - } - } - } - // TODO need to move these up a level? - val moreFocusRequester = remember { FocusRequester() } - val captionFocusRequester = remember { FocusRequester() } - val settingsFocusRequester = remember { FocusRequester() } - PlaybackControls( - modifier = Modifier.fillMaxWidth(), - playerControls = playerControls, - onPlaybackActionClick = onPlaybackActionClick, - controllerViewState = controllerViewState, - onSeekProgress = { - onSeekProgress(it) - }, - showPlay = showPlay, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBarInteractionSource = seekBarInteractionSource, - seekBarIntervals = 16, - seekBack = seekBack, - seekForward = seekForward, - skipBackOnResume = skipBackOnResume, - currentSegment = currentSegment, - onClickPlaybackDialogType = onClickPlaybackDialogType, - moreFocusRequester = moreFocusRequester, - captionFocusRequester = captionFocusRequester, - settingsFocusRequester = settingsFocusRequester, - ) - } -} 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 f8b80020..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,10 +1,16 @@ package com.github.damontecres.wholphin.ui.playback +import android.view.Gravity +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 @@ -22,11 +28,12 @@ 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,24 +41,25 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.layout.onSizeChanged 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 import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface @@ -61,34 +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.components.TextButton -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv +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.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.skipStringRes 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 kotlinx.coroutines.Dispatchers +import io.github.peerless2012.ass.media.widget.AssSubtitleView 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 @@ -112,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) } @@ -128,8 +139,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - player = playerState!!.player, - playerBackend = playerState!!.backend, + playerInstance = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -142,53 +152,27 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - player: Player, - playerBackend: PlayerBackend, + playerInstance: PlayerInstance, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { + 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.observeAsState(null) - var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } - - 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.subtitleSearch.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, - ) - } - } - } - AmbientPlayerListener(player) var contentScale by remember(playerBackend) { mutableStateOf( if (playerBackend == PlayerBackend.MPV) { @@ -201,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() } @@ -231,24 +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, - 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) { @@ -292,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 = - !segmentCancelled && currentSegment != null && - nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + state.currentSegment?.interacted == false && + state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L BackHandler(showSegment) { - segmentCancelled = 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 @@ -322,10 +350,14 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { + var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, + modifier = + scaledModifier.onSizeChanged { + playerSurfaceSize = it + }, ) if (presentationState.coverSurface) { Box( @@ -335,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 = { @@ -350,65 +431,83 @@ 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), + ) + } + } } } - // The playback controls - AnimatedVisibility( - controllerViewState.controlsVisible, - Modifier, - slideInVertically { it }, - slideOutVertically { it }, - ) { - PlaybackOverlay( + val controlsVisible = + remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) { + controllerViewState.controlsVisible || + playbackDialog != null || + subtitleSearchState.status != SubtitleSearchStatus.Inactive + } + if (!controlsVisible && skipIndicatorDuration == 0L) { + PauseIndicator( + player = player, modifier = Modifier - .padding(WindowInsets.systemBars.asPaddingValues()) - .fillMaxSize() - .background(Color.Transparent), - item = currentPlayback?.item, - playerControls = player, - controllerViewState = controllerViewState, - showPlay = playPauseState.showPlay, - previousEnabled = true, - nextEnabled = playlist.hasNext(), - seekEnabled = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = { playbackDialog = it }, - onSeekBarChange = seekBarState::onValueChange, - showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, - currentSegment = currentSegment, - showClock = preferences.appPreferences.interfacePreferences.showClock, + .align(Alignment.Center), ) } + // The playback controls + + PlaybackOverlay( + modifier = + Modifier + .padding(WindowInsets.systemBars.asPaddingValues()) + .fillMaxSize() + .background(Color.Transparent), + item = state.currentPlayback?.item, + player = player, + controllerViewState = controllerViewState, + showPlay = playPauseState.showPlay, + previousEnabled = true, + nextEnabled = state.hasNext, + seekEnabled = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = { playbackDialog = it }, + onSeekBarChange = seekBarState::onValueChange, + showDebugInfo = showDebugInfo, + currentPlayback = state.currentPlayback, + chapters = state.currentMediaInfo.chapters, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + queue = remember(state.playlistIndex, state.playlist) { state.upcomingItems }, + onClickPlaylist = viewModel::playItemInPlaylist, + currentSegment = state.currentSegment?.segment, + showClock = preferences.appPreferences.interfacePreferences.showClock, + 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 @@ -418,35 +517,89 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { - val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } - AndroidView( - factory = { context -> - SubtitleView(context).apply { - subtitleSettings.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) + val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + val isImageSubtitles = + remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } + + // 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.applyTo(this) + playerInstance.assHandler?.let { assHandler -> + if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { + Timber.v("Adding AssSubtitleView") + addView( + AssSubtitleView(context, assHandler).apply { + layoutParams = + FrameLayout + .LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ).apply { gravity = Gravity.CENTER } + }, + ) } } - }, - update = { - it.setCues(cues) + } + }, + update = { subtitleView -> + 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(it) - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(maxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent) - .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), - ) - } + .apply(subtitleView) + cueCount = state.subtitleCues.size + } + subtitleView.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + val resized = + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } + + if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) { + Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize) + layoutParams = + FrameLayout + .LayoutParams( + playerSurfaceSize.width, + playerSurfaceSize.height, + ).apply { gravity = Gravity.CENTER } + } + } + } + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(subtitleMaxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent) + .graphicsLayer { + alpha = + if (!subtitleVisible) { + 0f + } else if (isImageSubtitles) { + subtitleImageOpacity + } else { + 1f + } + }, + ) } // Ask to skip intros, etc button @@ -457,18 +610,17 @@ fun PlaybackPageContent( .padding(40.dp) .align(Alignment.BottomEnd), ) { - currentSegment?.let { segment -> + state.currentSegment?.let { segment -> val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() delay(10.seconds) - segmentCancelled = true + viewModel.updateSegment(segment.segment.id, true) } - TextButton( - stringRes = segment.type.skipStringRes, + SkipSegmentButton( + type = segment.segment.type, onClick = { - segmentCancelled = true - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + viewModel.updateSegment(segment.segment.id, false) }, modifier = Modifier.focusRequester(focusRequester), ) @@ -476,7 +628,7 @@ fun PlaybackPageContent( } // Next up episode - BackHandler(nextUp != null) { + BackHandler(state.nextUp != null) { if (player.isPlaying) { scope.launch(ExceptionHandler()) { viewModel.cancelUpNextEpisode() @@ -486,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( @@ -532,6 +684,7 @@ fun PlaybackPageContent( viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + runtime = it.data.runTimeTicks?.ticks, modifier = Modifier .padding(8.dp) @@ -548,7 +701,7 @@ fun PlaybackPageContent( } } - subtitleSearch?.let { state -> + if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) { val wasPlaying = remember { player.isPlaying } LaunchedEffect(Unit) { player.pause() @@ -559,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), + ) + } } } @@ -590,18 +744,30 @@ 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 || state.currentPlayback?.audioDecoder != null, + maxBitrate = state.maxBitrate, ), onDismissRequest = { - playbackDialog = null + playbackDialog = + when (type) { + // Go back to settings dialog + PlaybackDialogType.PLAYBACK_SPEED, + PlaybackDialogType.VIDEO_SCALE, + -> PlaybackDialogType.SETTINGS + + else -> null + } if (controllerViewState.controlsVisible) { controllerViewState.pulseControls() } 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/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt deleted file mode 100644 index 4bd59347..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.util.TrackSupport - -/** - * Debug info about the current playback tracks - */ -@Composable -fun PlaybackTrackInfo( - trackSupport: List<TrackSupport>, - modifier: Modifier = Modifier, -) { - val selectedWeight = .5f - val weights = listOf(.25f, .4f, .5f, 1f, 1f) - val textStyle = - MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface) - - LazyColumn( - modifier = modifier, - ) { - item { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - val texts = - listOf( - "ID", - "Type", - "Codec", - "Supported", - "Labels", - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.weight(selectedWeight), - ) { - Text( - text = "Selected", - style = textStyle, - ) - } - texts.forEachIndexed { index, text -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.weight(weights[index]), - ) { - Text( - text = text, - style = textStyle, - ) - } - } - } - } - items(trackSupport) { track -> - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier.ifElse( - track.selected, - Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)), - ), - ) { - val texts = - listOf( - track.id ?: "", - track.type.name, - track.codecs ?: "", - track.supported.name, - track.labels.joinToString(", "), - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.weight(selectedWeight), - ) { - if (track.selected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(12.dp), - ) - } else { - Text( - text = "-", - style = textStyle, - ) - } - } - texts.forEachIndexed { index, text -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.weight(weights[index]), - ) { - Text( - text = text, - style = textStyle, - ) - } - } - } - } - } -} 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 931ac551..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context +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 @@ -15,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 @@ -31,36 +33,45 @@ 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 +import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager 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.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.EqualityMutableLiveData 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 @@ -72,12 +83,15 @@ 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.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 @@ -95,13 +109,15 @@ import org.jellyfin.sdk.api.client.extensions.videosApi 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.MediaSegmentDto +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.MediaType 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 @@ -111,6 +127,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.Date import java.util.UUID +import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -128,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, @@ -137,6 +154,9 @@ class PlaybackViewModel private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + private val screensaverService: ScreensaverService, + private val musicService: MusicService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -146,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( @@ -159,37 +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 = EqualityMutableLiveData<MediaSegmentDto?>(null) - private val autoSkippedSegments = mutableSetOf<UUID>() - - val subtitleCues = MutableLiveData<List<Cue>>(listOf()) - - private lateinit var preferences: UserPreferences + internal lateinit var preferences: UserPreferences internal lateinit var itemId: UUID - internal lateinit var item: BaseItem + 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 var isPlaylist = false + private val isPlaylist = destination is Destination.PlaybackList - val playlist = MutableLiveData<Playlist>(Playlist(listOf())) - val subtitleSearch = MutableLiveData<SubtitleSearch?>(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 { disconnectPlayer() } - init() - } + initJob = + viewModelScope.launchIO { + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } + init() + } } private fun disconnectPlayer() { @@ -201,6 +223,9 @@ class PlaybackViewModel it.release() player.removeListener(it) } + this@PlaybackViewModel.trackChangeListener?.let { + player.removeListener(it) + } player.release() mediaSession?.release() } @@ -211,9 +236,13 @@ class PlaybackViewModel isHdr: Boolean, is4k: Boolean, ) { - val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val softwareDecoding = + !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val requestBackend = + (destination as? Destination.Playback)?.backend + ?: preferences.appPreferences.playbackPreferences.playerBackend val playerBackend = - when (preferences.appPreferences.playbackPreferences.playerBackend) { + when (requestBackend) { PlayerBackend.UNRECOGNIZED, PlayerBackend.EXO_PLAYER, -> PlayerBackend.EXO_PLAYER @@ -221,22 +250,25 @@ class PlaybackViewModel PlayerBackend.MPV -> PlayerBackend.MPV PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV + + PlayerBackend.EXTERNAL_PLAYER -> throw IllegalStateException("Cannot use this for external playback") } 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() } - player = + val playerCreation = playerFactory.createVideoPlayer( playerBackend, - preferences.appPreferences.playbackPreferences, + preferences.appPreferences, ) + this.player = playerCreation.player currentPlayer.update { - PlayerState(player, playerBackend) + PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -245,26 +277,31 @@ class PlaybackViewModel private fun configurePlayer() { player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) - jobs.add(subscribe()) + subscribeToWebSocket() jobs.add(listenForTranscodeReason()) val sessionPlayer = MediaSessionPlayer( player, - controllerViewState, preferences.appPreferences.playbackPreferences, ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() + mediaSession = playerFactory.createMediaSession(sessionPlayer) } /** * Initialize from the UI to start playback */ private suspend fun init() { - nextUp.setValueOnMain(null) + musicService.stop() 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() } } @@ -273,21 +310,18 @@ class PlaybackViewModel this.forceTranscoding = (destination as? Destination.Playback)?.forceTranscoding ?: false val positionMs: Long - val itemPlayback: ItemPlayback? val forceTranscoding: Boolean val itemId = when (val d = destination) { is Destination.Playback -> { positionMs = d.positionMs - itemPlayback = d.itemPlayback forceTranscoding = d.forceTranscoding d.itemId } is Destination.PlaybackList -> { positionMs = 0 - itemPlayback = null forceTranscoding = false d.itemId } @@ -298,23 +332,37 @@ class PlaybackViewModel } this.itemId = itemId val queriedItem = api.userLibraryApi.getItem(itemId).content - val base = + val playlistItem = if (queriedItem.type.playable) { - queriedItem - } else if (destination is Destination.PlaybackList) { - isPlaylist = true + PlaylistItem.Media(BaseItem(queriedItem, false)) + } 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 } @@ -324,26 +372,45 @@ class PlaybackViewModel navigationManager.goBack() return } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist + _state.update { + it.copy(playlist = r.playlist) } - r.playlist.items - .first() - .data + r.playlist.items.first() } } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - val item = BaseItem.from(base, api) + val intros = + // If not resuming playback & cinema mode is enabled, get potential intros + if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) { + api.userLibraryApi + .getIntros( + itemId = playlistItem.id, + userId = serverRepository.currentUser?.id, + ).content.items + .map { + PlaylistItem.Intro(BaseItem(it)) + } + } else { + emptyList() + } + val firstItem = + if (intros.isNotEmpty()) { + Timber.v("Got %s intros", intros.size) + _state.update { + it.copy(playlist = Playlist(intros + it.playlist.items)) + } + intros.first() + } else { + playlistItem + } + val played = play( - item, + firstItem, positionMs, - itemPlayback, forceTranscoding, ) if (!played) { @@ -353,33 +420,46 @@ class PlaybackViewModel if (!isPlaylist) { val result = playlistCreator.createFrom(queriedItem) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = result.playlist + _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 * - * @param item the item to play + * @param currentItem the item to play * @param positionMs the starting playback position in milliseconds * @param itemPlayback the parameters for playback such chosen subtitle or audio streams * @param forceTranscoding whether the user has requested to force playback via transcoding */ private suspend fun play( - item: BaseItem, + playlistItem: PlaylistItem, positionMs: Long, - itemPlayback: ItemPlayback? = null, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { + val item = + when (playlistItem) { + is PlaylistItem.Intro -> playlistItem.item + is PlaylistItem.Media -> playlistItem.item + } + Timber.i("Playing ${item.id}") // 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 @@ -394,29 +474,43 @@ class PlaybackViewModel ) return@withContext false } - this@PlaybackViewModel.item = item + this@PlaybackViewModel.currentItem = playlistItem this@PlaybackViewModel.itemId = item.id val isLiveTv = item.type == BaseItemKind.TV_CHANNEL val base = item.data // Use the provided playback parameters or else check if the database has some - val playbackConfig = - itemPlayback - ?: serverRepository.currentUser.value?.let { user -> - itemPlaybackDao.getItem(user, base.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId != null) { - it - } else { - null - } + val itemPlayback = + serverRepository.currentUser?.let { user -> + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null } } - val mediaSource = streamChoiceService.chooseSource(base, playbackConfig) - 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...", @@ -426,7 +520,8 @@ class PlaybackViewModel } val videoStream = - mediaSource.mediaStreams + mediaSource + ?.mediaStreams ?.firstOrNull { it.type == MediaStreamType.VIDEO } ?.let { val isHdr = @@ -440,59 +535,49 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - val subtitleStreams = - mediaSource.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.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 = playbackConfig, - 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 = playbackConfig, - 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 = - playbackConfig ?: 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 { @@ -505,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, ) @@ -542,220 +630,274 @@ 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()) - .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 activityListener = - TrackActivityPlaybackListener( - api = api, - player = player, - playback = playback, - itemPlayback = currentItemPlayback, - ) - 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, - ) - 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 activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + 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 @@ -764,26 +906,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, @@ -791,37 +942,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 @@ -834,78 +962,112 @@ class PlaybackViewModel fun changeAudioStream(index: Int) { viewModelScope.launchIO { - Timber.d("Changing audio track to %s", index) - val itemPlayback = - itemPlaybackRepository.saveTrackSelection( - item = 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 = 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( - 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 = 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 = item.data.seriesId, + source = currentPlayback.mediaSourceInfo, + audioStreamIndex = currentPlayback.audioIndex, + item = currentItem.item.data, subtitleIndex = index, prefs = preferences, ) - } else { - index.takeIf { it >= 0 } - } - changeStreams( - 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, @@ -929,11 +1091,15 @@ 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 = item.id + val itemId = currentItem.id return api.trickplayApi.getTrickplayTileImageUrl( itemId, trickPlayInfo.width, @@ -944,12 +1110,40 @@ class PlaybackViewModel override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { - viewModelScope.launchIO { - val nextItem = playlist.value?.peek() - Timber.v("Setting next up to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem - if (nextItem == null) { + Timber.v("Playback state is STATE_ENDED") + viewModelScope.launchDefault { + when (val nextItem = state.value.nextItem()) { + is PlaylistItem.Intro -> { + Timber.v("Next item is intro, so playing immediately") + playNextUp() + } + + is PlaylistItem.Media -> { + 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() + } + } + } + + null -> { + Timber.v("No next up") navigationManager.goBack() } } @@ -957,15 +1151,20 @@ class PlaybackViewModel } } - private var segmentJob: Job? = null + // Variables for tracking segment state + @VisibleForTesting + internal var segmentJob: Job? = null + private val autoSkippedSegments = mutableSetOf<UUID>() + private val outroShownSegments = mutableSetOf<UUID>() /** * Cancels listening for segments and clears current segment state */ - private suspend fun resetSegmentState() { + private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() - currentSegment.setValueOnMain(null) + outroShownSegments.clear() + _state.update { it.copy(currentSegment = null) } } /** @@ -988,25 +1187,31 @@ class PlaybackViewModel it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } if (currentSegment != null && - currentSegment.itemId == this@PlaybackViewModel.itemId && - autoSkippedSegments.add(currentSegment.id) + currentSegment.itemId == this@PlaybackViewModel.itemId ) { - Timber.d( - "Found media segment for %s: %s, %s", - currentSegment.itemId, - currentSegment.id, - currentSegment.type, - ) - val playlist = this@PlaybackViewModel.playlist.value + if (currentSegment.id != + state.value.currentSegment + ?.segment + ?.id + ) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + currentSegment.id, + currentSegment.type, + ) + } + 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() - Timber.v("Setting next up during outro to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem + val nextItem = state.nextItem() + if (nextItem is PlaylistItem.Media) { + Timber.v("Setting next up during outro to ${nextItem?.id}") + _state.update { it.copy(nextUp = nextItem.item) } } } else { val behavior = @@ -1018,41 +1223,63 @@ class PlaybackViewModel MediaSegmentType.INTRO -> prefs.skipIntros MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } - withContext(Dispatchers.Main) { - when (behavior) { - SkipSegmentBehavior.AUTO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = null - player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) - } + 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.value = - currentSegment - } + 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) } } } } } } + fun updateSegment( + segmentId: UUID?, + dismissed: Boolean, + ) { + viewModelScope.launchDefault { + val segment = state.value.currentSegment?.segment + if (segment != null && segment.id == segmentId) { + autoSkippedSegments.add(segment.id) + if (dismissed) { + _state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) } + } else { + _state.update { it.copy(currentSegment = null) } + onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } + } + } + } + } + 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 @@ -1063,13 +1290,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) } } @@ -1099,57 +1326,85 @@ class PlaybackViewModel } fun playNextUp() { - playlist.value?.let { - if (it.hasNext()) { - viewModelScope.launchIO { + 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.launchIO { + 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), ) @@ -1157,36 +1412,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( - 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() } @@ -1202,73 +1463,123 @@ 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)) } } + private fun updateDecoder( + decoderName: String, + type: MediaType, + ) { + viewModelScope.launchDefault { + val codecInfo = + MediaCodecList(MediaCodecList.ALL_CODECS) + .codecInfos + .firstOrNull { !it.isEncoder && it.name == decoderName } + val decoderString = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (codecInfo?.isHardwareAccelerated == true) { + "$decoderName (HW)" + } else { + decoderName + } + } else { + decoderName + } + updateCurrentPlayback { + when (type) { + MediaType.VIDEO -> it?.copy(videoDecoder = decoderString) + MediaType.AUDIO -> it?.copy(audioDecoder = decoderString) + else -> throw IllegalArgumentException("Unsupported type: $type") + } + } + } + } + override fun onVideoDecoderInitialized( eventTime: AnalyticsListener.EventTime, decoderName: String, @@ -1276,7 +1587,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.v("onVideoDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(videoDecoder = decoderName) } + updateDecoder(decoderName, MediaType.VIDEO) } override fun onVideoDisabled( @@ -1284,7 +1595,7 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("onVideoDisabled") - currentPlayback.update { it?.copy(videoDecoder = null) } + updateCurrentPlayback { it?.copy(videoDecoder = null) } } override fun onVideoInputFormatChanged( @@ -1295,7 +1606,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}") - currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) } + updateDecoder(decoder.decoderName, MediaType.VIDEO) } } } @@ -1307,7 +1618,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName") - currentPlayback.update { it?.copy(audioDecoder = decoderName) } + updateDecoder(decoderName, MediaType.AUDIO) } override fun onAudioInputFormatChanged( @@ -1318,7 +1629,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}") - currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) } + updateDecoder(decoder.decoderName, MediaType.AUDIO) } } } @@ -1328,21 +1639,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, @@ -1357,25 +1668,95 @@ 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) } } } } } - } -data class PlayerState( - val player: Player, - val backend: PlayerBackend, -) + override fun onIsPlayingChanged(isPlaying: Boolean) { + 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, + totalBytesLoaded: Long, + bitrateEstimate: Long, + ) { + Timber.v( + "onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s", + totalLoadTimeMs, + totalBytesLoaded, + bitrateEstimate, + ) + if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) { + _state.update { + it.copy( + analyticsState = + it.analyticsState.copy( + bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), + bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), + ), + ) + } + } + } + + override fun onDroppedVideoFrames( + eventTime: AnalyticsListener.EventTime, + droppedFrames: Int, + elapsedMs: Long, + ) { + _state.update { + it.copy( + analyticsState = + it.analyticsState.copy( + droppedFrames = it.analyticsState.droppedFrames + droppedFrames, + ), + ) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt deleted file mode 100644 index c966a9aa..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt +++ /dev/null @@ -1,39 +0,0 @@ -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.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.media3.common.Player -import androidx.media3.common.listen -import androidx.media3.common.util.UnstableApi - -@UnstableApi -@Composable -fun rememberPlayerLoadingState(player: Player): PlayerLoadingState { - val state = remember(player) { PlayerLoadingState(player) } - LaunchedEffect(player) { - state.observe() - } - return state -} - -@UnstableApi -class PlayerLoadingState( - private val player: Player, -) : State<Boolean> { - override var value by mutableStateOf(player.isLoading) - private set - - suspend fun observe() { - value = player.isLoading - player.listen { - if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) { - value = player.isLoading - } - } - } -} 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/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt new file mode 100644 index 00000000..6925d5cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.playback + +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 + +/** + * Shared seek acceleration profile for hold-to-seek behavior. + * Keep this in sync anywhere directional key repeat seeking is handled. + */ +fun calculateSeekAccelerationMultiplier( + repeatCount: Int, + durationMs: Long, +): Int { + if (repeatCount <= 0 || durationMs <= 0L) return 1 + + // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. + val scaledRepeatCount = repeatCount / 3 + if (scaledRepeatCount <= 0) return 1 + + val durationMinutes = durationMs / 60_000L + return when { + durationMinutes < 30 -> { + if (scaledRepeatCount < 30) 1 else 2 + } + + durationMinutes < 90 -> { + when { + scaledRepeatCount < 13 -> 1 + scaledRepeatCount < 50 -> 2 + scaledRepeatCount < 75 -> 3 + else -> 4 + } + } + + durationMinutes < 150 -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 2 + scaledRepeatCount < 60 -> 4 + else -> 6 + } + } + + else -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 3 + scaledRepeatCount < 60 -> 6 + else -> 10 + } + } + } +} 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 6444a268..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,10 +1,15 @@ 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 +/** + * A slimmer [MediaStream] with minimal data for UI purposes + * + * @see SimpleVideoStream + */ data class SimpleMediaStream( val index: Int, val streamTitle: String?, @@ -12,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/SubtitleDelay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt index 17792899..8c645c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt @@ -30,6 +30,9 @@ import kotlin.time.Duration.Companion.seconds private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds) +/** + * Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay + */ @Composable fun SubtitleDelay( delay: Duration, 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 021f4290..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 @@ -5,88 +5,121 @@ import androidx.compose.ui.text.intl.Locale import androidx.lifecycle.viewModelScope 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 SubtitleSearch { - data object Searching : SubtitleSearch +sealed interface SubtitleSearchStatus { + data object Inactive : SubtitleSearchStatus - data object Downloading : SubtitleSearch + data object Searching : SubtitleSearchStatus + + data object Downloading : SubtitleSearchStatus data class Success( val options: List<RemoteSubtitleInfo>, - ) : SubtitleSearch + ) : SubtitleSearchStatus data class Error( val message: String?, val ex: Exception?, - ) : SubtitleSearch + ) : SubtitleSearchStatus } -fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { - subtitleSearch.value = SubtitleSearch.Searching - subtitleSearchLanguage.value = language +/** + * Trigger a search for subtitles in the given language for the currently playing media + */ +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 }, ) - subtitleSearch.setValueOnMain(SubtitleSearch.Success(results)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } } } catch (ex: Exception) { Timber.e(ex, "Exception while searching for subtitles") - subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) } } } } +/** + * Download the remote subtitles and attempt to activate them once complete + */ fun PlaybackViewModel.downloadAndSwitchSubtitles( subtitleId: String?, wasPlaying: Boolean, ) { if (subtitleId == null) { - subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + "Subtitle has no ID", + null, + ), + ) + } } else { - subtitleSearch.value = SubtitleSearch.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 } @@ -99,13 +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) - item = - BaseItem.from( - api.userLibraryApi.getItem(itemId = it.itemId).content, - api, - ) - mediaSource = streamChoiceService.chooseSource(item.data, it) + 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 = + base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId } if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -136,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( - 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, ) } } - subtitleSearch.setValueOnMain(null) - withContext(Dispatchers.Main) { + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } + withContext(WholphinDispatchers.Main) { if (wasPlaying) { player.play() } @@ -170,12 +221,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } catch (ex: Exception) { Timber.e(ex, "Exception while downloading subtitles: $subtitleId") - subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + null, + ex, + ), + ) + } } } } } fun PlaybackViewModel.cancelSubtitleSearch() { - subtitleSearch.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 201b5982..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,211 +7,173 @@ 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] + */ object TrackSelectionUtils { @OptIn(UnstableApi::class) 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 && 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 { - audioIndex == null + 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") - } - } } - } + }.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 */ @@ -258,10 +220,23 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = } } +/** + * The result of [TrackSelectionUtils.createTrackSelections] + */ 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 new file mode 100644 index 00000000..fa32950b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt @@ -0,0 +1,158 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +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.LaunchedEffect +import androidx.compose.runtime.remember +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.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.ui.cards.ChapterCard +import com.github.damontecres.wholphin.ui.components.HiddenFocusBox +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun ChapterRowOverlay( + player: Player, + controllerViewState: ControllerViewState, + chapters: List<Chapter>, + 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() } + val chapterIndex = + remember { + val position = player.currentPosition.milliseconds + val index = + chapters + .indexOfFirst { it.position > position } + .minus(1) + .let { + if (it < 0) { + // Didn't find a chapter, so it's either the first or last + if (position < chapters.first().position) { + 0 + } else { + chapters.lastIndex + } + } else { + it + } + }.coerceIn(0, chapters.lastIndex) + index + } + val listState = rememberLazyListState(chapterIndex) + val focusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + focusRequester.tryRequestFocus() + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + HiddenFocusBox(hiddenFocusRequester) { + onChangeState.invoke(OverlayViewState.CONTROLLER) + } + Text( + text = stringResource(R.string.chapters), + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + state = listState, + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }.focusProperties { + up = hiddenFocusRequester + }, + ) { + itemsIndexed(chapters) { index, chapter -> + val interactionSource = chapterInteractionSources[index] + val isFocused = + interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + ChapterCard( + chapter = chapter, + onClick = { + player.seekTo(chapter.position.inWholeMilliseconds) + controllerViewState.hideControls() + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester( + bringIntoViewRequester, + ), + ).ifElse( + index == 0, + Modifier.focusProperties { + // Prevent scrolling left on first card to prevent moving down + left = + if (isLtr) FocusRequester.Cancel else FocusRequester.Default + right = + if (isLtr) FocusRequester.Default else FocusRequester.Cancel + }, + ), + ) + } + } + if (hasNext) { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(bottom = 8.dp) + .onFocusChanged { + if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE) + }.focusable(), + ) + } + } +} 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/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt new file mode 100644 index 00000000..8414d5c5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt @@ -0,0 +1,101 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.layout.size +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.observeState +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.R +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Show an animated "pause" image whenever the player is paused + */ +@Composable +fun PauseIndicator( + player: Player, + modifier: Modifier = Modifier, + duration: Duration = 300.milliseconds, +) { + val state = rememberPauseState(player) + var visible by remember { mutableStateOf(false) } + LaunchedEffect(state.isPaused) { + if (state.isPaused) visible = true + } + AnimatedVisibility( + visible = visible, + enter = + scaleIn( + animationSpec = + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + ), + ), + exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow)), + modifier = modifier, + ) { + LaunchedEffect(Unit) { + delay(duration) + delay(50) + visible = false + } + Icon( + modifier = Modifier.size(64.dp, 64.dp), + painter = painterResource(id = R.drawable.baseline_pause_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null, + ) + } +} + +/** + * Remember when a player is paused + */ +@Composable +fun rememberPauseState(player: Player): PauseState { + val state = remember(player) { PauseState(player) } + LaunchedEffect(player) { state.observe() } + return state +} + +class PauseState( + private val player: Player, +) { + var isPaused by mutableStateOf(false) + private set + + @OptIn(UnstableApi::class) + internal suspend fun observe() { + player + .observeState( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + ) { + // Timber.v("isPaused=$isPaused, playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}") + isPaused = !isPaused && // Not already paused, don't want to trigger more than once + !player.playWhenReady && // Player is actually paused + // Player could play if it was not paused, ie it is not stopped + player.playbackState.let { it == Player.STATE_READY || it == Player.STATE_BUFFERING } + }.observe() + } +} 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 new file mode 100644 index 00000000..2909e666 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt @@ -0,0 +1,248 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +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.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.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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.BaseItem +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 +import kotlinx.coroutines.isActive +import org.jellyfin.sdk.model.api.MediaSegmentDto +import java.time.LocalTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +fun PlaybackController( + item: BaseItem?, + nextState: OverlayViewState?, + player: Player, + controllerViewState: ControllerViewState, + showPlay: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + seekEnabled: Boolean, + seekBack: Duration, + skipBackOnResume: Duration?, + seekForward: Duration, + onPlaybackActionClick: (PlaybackAction) -> Unit, + onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + onSeekBarChange: (Long) -> Unit, + currentSegment: MediaSegmentDto?, + onChangeState: (OverlayViewState) -> Unit, + modifier: Modifier = Modifier, + seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Controller( + title = item?.title, + subtitle = item?.subtitleLong, + player = player, + controllerViewState = controllerViewState, + showPlay = showPlay, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBack = seekBack, + skipBackOnResume = skipBackOnResume, + seekForward = seekForward, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = onClickPlaybackDialogType, + onSeekProgress = onSeekBarChange, + seekBarInteractionSource = seekBarInteractionSource, + currentSegment = currentSegment, + modifier = Modifier, + ) + when (nextState) { + OverlayViewState.CHAPTERS -> { + Text( + text = stringResource(R.string.chapters), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 0.dp) + .onFocusChanged { + if (it.isFocused) onChangeState.invoke(nextState) + }.focusable(), + ) + } + + OverlayViewState.QUEUE -> { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 0.dp) + .onFocusChanged { + if (it.isFocused) onChangeState.invoke(nextState) + }.focusable(), + ) + } + + else -> { + Unit + } + } + } +} + +internal val titleTextSize = 28.sp +internal val subtitleTextSize = 18.sp + +/** + * A wrapper for the playback controls to show title and other information, plus the actual controls + * + * @see PlaybackControls + */ +@Composable +fun Controller( + title: String?, + player: Player, + controllerViewState: ControllerViewState, + showPlay: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + seekEnabled: Boolean, + seekBack: Duration, + skipBackOnResume: Duration?, + seekForward: Duration, + onPlaybackActionClick: (PlaybackAction) -> Unit, + onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + onSeekProgress: (Long) -> Unit, + currentSegment: MediaSegmentDto?, + modifier: Modifier = Modifier, + subtitle: String? = null, + seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() + val verticalOffset by animateDpAsState( + targetValue = if (seekBarFocused) (-32).dp else 0.dp, + label = "TitleBumpOffset", + animationSpec = + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Dp.VisibilityThreshold, + ), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(start = 16.dp) + .offset(y = verticalOffset), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontSize = titleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.End), + ) { + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontSize = subtitleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + + val context = LocalContext.current + var endTimeStr by remember { mutableStateOf("...") } + LaunchedEffect(player, context) { + while (isActive) { + val remaining = + (player.duration - player.currentPosition) + .div(player.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = formatTime(context, endTime) + delay(1.seconds) + } + } + Text( + text = stringResource(R.string.ends_at, endTimeStr), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) + } + } + PlaybackControls( + modifier = Modifier.fillMaxWidth(), + player = player, + onPlaybackActionClick = onPlaybackActionClick, + controllerViewState = controllerViewState, + onSeekProgress = { + onSeekProgress(it) + }, + showPlay = showPlay, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBarInteractionSource = seekBarInteractionSource, + seekBarIntervals = 16, + seekBack = seekBack, + seekForward = seekForward, + skipBackOnResume = skipBackOnResume, + currentSegment = currentSegment, + onClickPlaybackDialogType = onClickPlaybackDialogType, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt similarity index 65% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index 75ac0951..45fcdd4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -1,14 +1,14 @@ @file:kotlin.OptIn(ExperimentalMaterial3Api::class) -package com.github.damontecres.wholphin.ui.playback +package com.github.damontecres.wholphin.ui.playback.overlay import android.view.Gravity import androidx.annotation.DrawableRes import androidx.annotation.OptIn +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup 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 @@ -22,12 +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.CircleShape 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 @@ -37,14 +38,21 @@ import androidx.compose.runtime.rememberCoroutineScope 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.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 import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider @@ -60,10 +68,15 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.FontAwesome 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 import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.skipStringRes @@ -79,6 +92,9 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Possible actions the user can take during playback + */ sealed interface PlaybackAction { data object ShowDebug : PlaybackAction @@ -107,18 +123,22 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction + + data class ChangeBitrate( + val maxBitrate: Long, + ) : PlaybackAction } +/** + * UI for actual playback controls such as seek bar, play/pause and seek buttons + */ @OptIn(UnstableApi::class) @Composable fun PlaybackControls( - playerControls: Player, + player: Player, controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - moreFocusRequester: FocusRequester, - captionFocusRequester: FocusRequester, - settingsFocusRequester: FocusRequester, onSeekProgress: (Long) -> Unit, showPlay: Boolean, previousEnabled: Boolean, @@ -153,7 +173,7 @@ fun PlaybackControls( verticalArrangement = Arrangement.spacedBy(0.dp), ) { SeekBar( - player = playerControls, + player = player, controllerViewState = controllerViewState, onSeekProgress = onSeekProgress, interactionSource = seekBarInteractionSource, @@ -173,13 +193,13 @@ fun PlaybackControls( .fillMaxWidth(), ) { LeftPlaybackButtons( - moreFocusRequester = moreFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) + PlaybackButtons( - player = playerControls, + player = player, initialFocusRequester = initialFocusRequester, onControllerInteraction = onControllerInteraction, onPlaybackActionClick = onPlaybackActionClick, @@ -191,6 +211,7 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) + Row( modifier = Modifier.align(Alignment.CenterEnd), ) { @@ -198,7 +219,7 @@ fun PlaybackControls( TextButton( stringRes = segment.type.skipStringRes, onClick = { - playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) }, modifier = Modifier @@ -207,8 +228,6 @@ fun PlaybackControls( ) } RightPlaybackButtons( - captionFocusRequester = captionFocusRequester, - settingsFocusRequester = settingsFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier, @@ -261,36 +280,61 @@ 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), + ) + } } } } -private val buttonSpacing = 12.dp +val buttonSpacing = 12.dp @Composable fun LeftPlaybackButtons( - moreFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -299,24 +343,21 @@ fun LeftPlaybackButtons( modifier = modifier.focusGroup(), horizontalArrangement = Arrangement.spacedBy(buttonSpacing), ) { - // More options PlaybackButton( - iconRes = R.drawable.baseline_more_vert_96, + iconRes = R.drawable.vector_settings, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE) + onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) }, enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(moreFocusRequester), + modifier = Modifier, ) } } @Composable fun RightPlaybackButtons( - captionFocusRequester: FocusRequester, - settingsFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -334,18 +375,18 @@ fun RightPlaybackButtons( onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS) }, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(captionFocusRequester), + modifier = Modifier, ) - // Playback speed, etc - PlaybackButton( - iconRes = R.drawable.vector_settings, + // Audio + PlaybackFaButton( + enabled = true, + iconRes = R.string.fa_volume_high, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) + onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO) }, - enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier.focusRequester(settingsFocusRequester), + modifier = Modifier, ) } } @@ -365,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, + ) + } } } @@ -462,6 +505,59 @@ fun PlaybackButton( } } +@Composable +fun PlaybackFaButton( + @StringRes iconRes: Int, + onClick: () -> Unit, + onControllerInteraction: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + textColor: Color = Color.Unspecified, +) { + val selectedColor = MaterialTheme.colorScheme.border + Button( + enabled = enabled, + onClick = onClick, +// shape = ButtonDefaults.shape(CircleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = AppColors.TransparentBlack25, + focusedContainerColor = selectedColor, + ), + contentPadding = PaddingValues(4.dp), + interactionSource = interactionSource, + modifier = + modifier + .size(36.dp, 36.dp) + .onFocusChanged { onControllerInteraction.invoke() }, + ) { + Text( + text = stringResource(iconRes), + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + color = + if (textColor.isSpecified) { + textColor + } else if (LocalTheme.current == AppThemeColors.OLED_BLACK) { + LocalContentColor.current + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + } +} + +/** + * Show a dialog aligned to the bottom of the screen instead of centered. + * + * @param gravity The [Gravity] to align the dialog to left or right + */ @Composable fun <T> BottomDialog( choices: List<BottomDialogItem<T>>, @@ -470,6 +566,21 @@ 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) { + bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() + focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -492,6 +603,7 @@ fun <T> BottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -504,6 +616,7 @@ fun <T> BottomDialog( val interactionSource = remember { MutableInteractionSource() } ListItem( selected = choice == currentChoice, + enabled = choice.enabled, onClick = { onDismissRequest() onSelectChoice(index, choice) @@ -524,6 +637,10 @@ fun <T> BottomDialog( } }, interactionSource = interactionSource, + modifier = + Modifier + .bringIntoViewRequester(bringIntoViewRequesters[index]) + .focusRequester(focusRequesters[index]), ) } } @@ -531,26 +648,29 @@ fun <T> BottomDialog( } } -data class MoreButtonOptions( - val options: Map<String, PlaybackAction>, -) - data class BottomDialogItem<T>( val data: T, val headline: String, val supporting: String?, + val enabled: Boolean = true, ) @PreviewTvSpec @Composable private fun ButtonPreview() { WholphinTheme { - Row { + Row(Modifier.background(Color.Red)) { PlaybackButton( iconRes = R.drawable.baseline_play_arrow_24, onClick = {}, onControllerInteraction = {}, ) + PlaybackFaButton( + iconRes = R.string.fa_volume_high, + onClick = {}, + onControllerInteraction = {}, + textColor = Color.Green, + ) } } } 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 new file mode 100644 index 00000000..2aecdc65 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt @@ -0,0 +1,298 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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 androidx.compose.ui.unit.sp +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.formatBitrate +import com.github.damontecres.wholphin.ui.formatBytes +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.playback.AnalyticsState +import com.github.damontecres.wholphin.ui.playback.CurrentPlayback +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 com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +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.HardwareAccelerationType +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.MediaStreamProtocol +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.TranscodeReason +import org.jellyfin.sdk.model.api.TranscodingInfo +import timber.log.Timber +import java.util.Locale +import kotlin.time.Duration.Companion.seconds + +@Composable +fun PlaybackDebugOverlay( + analyticsState: AnalyticsState, + currentPlayback: CurrentPlayback?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val display = + remember(context) { + try { + val displayManager = + context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + displayManager?.getDisplay(Display.DEFAULT_DISPLAY) + } catch (ex: Exception) { + Timber.e(ex) + null + } + } + val displayMode by produceState<String?>(null) { + while (isActive) { + value = + display?.mode?.let { + val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate) + "${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}" + } + delay(10.seconds) + } + } + val textStyle = + MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + + val memoryUsed by produceState("") { + withContext(WholphinDispatchers.Default) { + while (isActive) { + val runtime = Runtime.getRuntime() + val total = runtime.totalMemory() + val free = runtime.freeMemory() + val used = total - free + val totalMemory = formatBytes(total) + val usedMemory = formatBytes(used) + value = "$usedMemory / $totalMemory" + delay(2.seconds) + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + ) { + ProvideTextStyle(textStyle) { + SimpleTable( + remember(currentPlayback, displayMode) { + buildList { + add("Backend:" to currentPlayback?.backend?.toString()) + add("Play method:" to currentPlayback?.playMethod?.serialName) + if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) { + add("Video Decoder:" to currentPlayback.videoDecoder) + add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer")) + } + add("Display Mode: " to displayMode) + } + }, + modifier = Modifier.weight(1f, fill = false), + keyWidth = 80.dp, + ) + SimpleTable( + rows = + listOf( + "Bitrate: " to analyticsState.bitrate, + "Bitrate (est): " to analyticsState.bitrateEstimate, + "Dropped frames: " to analyticsState.droppedFrames, + "Memory" to memoryUsed, + ), + keyWidth = 80.dp, + modifier = Modifier.weight(1f, fill = false), + ) + currentPlayback?.transcodeInfo?.let { info -> + SimpleTable( + listOf( + "Reason:" to info.transcodeReasons.joinToString(", "), + "HW Accel:" to info.hardwareAccelerationType?.toString(), + "Container:" to info.container, + "Bitrate:" to info.bitrate?.let { formatBitrate(it) }, + ), + modifier = Modifier.weight(1f, fill = false), + keyWidth = 64.dp, + ) + SimpleTable( + listOf( + "Video:" to "${info.videoCodec}, ${info.width}x${info.height}", + "Video Direct:" to info.isVideoDirect.toString(), + "Audio:" to "${info.audioCodec}, ch=${info.audioChannels}", + "Audio Direct:" to info.isAudioDirect.toString(), + ), + modifier = Modifier.weight(1f, fill = false), + keyWidth = 64.dp, + ) + } + } + } + currentPlayback?.tracks?.letNotEmpty { + PlaybackTrackInfo( + trackSupport = it, + textStyle = textStyle, + ) + } + } +} + +@Composable +fun SimpleTable( + rows: List<Pair<String, Any?>>, + modifier: Modifier = Modifier, + keyWidth: Dp = 100.dp, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + rows.forEach { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = it.first, + modifier = Modifier.width(keyWidth), + ) + Text( + text = it.second.toString(), + modifier = Modifier, + ) + } + } + } +} + +@OptIn(UnstableApi::class) +@PreviewTvSpec +@Composable +fun PlaybackDebugOverlayPreview() { + val currentPlayback = + CurrentPlayback( + item = + BaseItem( + data = + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + ), + ), + backend = PlayerBackend.EXO_PLAYER, + playMethod = PlayMethod.TRANSCODE, + playSessionId = "123", + liveStreamId = "123", + videoDecoder = "video.decoder.name", + audioDecoder = null, // "audio.decoder.name", + mediaSourceInfo = + MediaSourceInfo( + protocol = MediaProtocol.HTTP, + type = MediaSourceType.DEFAULT, + isRemote = false, + readAtNativeFramerate = true, + ignoreDts = true, + ignoreIndex = true, + genPtsInput = false, + supportsTranscoding = true, + supportsDirectStream = true, + supportsDirectPlay = true, + isInfiniteStream = false, + requiresOpening = false, + requiresClosing = false, + requiresLooping = false, + supportsProbing = true, + transcodingSubProtocol = MediaStreamProtocol.HTTP, + hasSegments = false, + ), + transcodeInfo = + TranscodingInfo( + videoCodec = "h264", + audioCodec = "aac", + container = "HLS", + width = 1080, + height = 1920, + isVideoDirect = false, + isAudioDirect = false, + transcodeReasons = + listOf( + TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED, + TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED, + ), + hardwareAccelerationType = HardwareAccelerationType.NONE, + ), + tracks = + listOf( + TrackSupport( + id = "0", + type = TrackType.VIDEO, + supported = TrackSupportReason.EXCEEDS_CAPABILITIES, + selected = true, + labels = listOf(), + codecs = "avc1", + format = Format.Builder().build(), + ), + TrackSupport( + id = "0", + type = TrackType.AUDIO, + supported = TrackSupportReason.HANDLED, + selected = true, + labels = listOf(), + codecs = "ac3", + format = Format.Builder().build(), + ), + ) + + List(10) { + TrackSupport( + id = "0", + type = TrackType.TEXT, + supported = TrackSupportReason.HANDLED, + selected = false, + labels = listOf(), + codecs = "srt", + format = Format.Builder().build(), + ) + }, + ) + WholphinTheme { + PlaybackDebugOverlay( + analyticsState = AnalyticsState(), + currentPlayback = currentPlayback, + modifier = Modifier.fillMaxSize(), + ) + } +} 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 new file mode 100644 index 00000000..d00fefea --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -0,0 +1,376 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +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.fillMaxSize +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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.onFocusChanged +import androidx.compose.ui.graphics.Brush +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 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.PlaylistItem +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playback.AnalyticsState +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.playback.CurrentPlayback +import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType +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 + +/** + * The overlay during playback showing controls, seek preview image, debug info, etc + */ +@Composable +fun PlaybackOverlay( + item: BaseItem?, + chapters: List<Chapter>, + player: Player, + controllerViewState: ControllerViewState, + showPlay: Boolean, + showClock: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + seekEnabled: Boolean, + seekBack: Duration, + skipBackOnResume: Duration?, + seekForward: Duration, + onPlaybackActionClick: (PlaybackAction) -> Unit, + onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + onSeekBarChange: (Long) -> Unit, + showDebugInfo: Boolean, + currentPlayback: CurrentPlayback?, + currentSegment: MediaSegmentDto?, + analyticsState: AnalyticsState, + queue: List<PlaylistItem>, + modifier: Modifier = Modifier, + trickplayInfo: TrickplayInfo? = null, + trickplayUrlFor: (Int) -> String? = { null }, + onClickPlaylist: (BaseItem) -> Unit = {}, + seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() + // Will be used for preview/trick play images + var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) } + var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat() + + val density = LocalDensity.current + + val titleHeight = + remember(item?.title, density) { + if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp + } + val subtitleHeight = + remember(item?.subtitleLong, density) { + if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp + } + + // This will be calculated after composition + var controllerHeight by remember { mutableStateOf(0.dp) } + var state by remember(controllerViewState.controlsVisible) { + mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN) + } + val onChangeState = { newState: OverlayViewState -> + state = newState + } + + Box( + modifier = modifier, + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility( + visible = controllerViewState.controlsVisible && !showDebugInfo, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.matchParentSize(), + ) { + // Background scrim for OSD readability + val scrimBrush = + remember { + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.80f), + ), + ) + } + Box( + modifier = + Modifier + .fillMaxSize() + .background(scrimBrush), + ) + } + + AnimatedContent( + targetState = state, + label = "controls transition", + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + // Moving down, so move content up + (slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut()) + } else { + // Moving up + (slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut()) + } + }, + ) { targetState -> + when (targetState) { + OverlayViewState.HIDDEN -> { + // Necessary so the bounds for animation are full width + Box(Modifier.fillMaxWidth()) + } + + OverlayViewState.CONTROLLER -> { + if (seekBarFocused) { + LaunchedEffect(Unit) { + seekProgressPercent = + (player.currentPosition.toFloat() / player.duration) + } + } + val nextState = + remember(chapters, nextEnabled) { + if (chapters.isNotEmpty()) { + OverlayViewState.CHAPTERS + } else if (nextEnabled) { + OverlayViewState.QUEUE + } else { + null + } + } + PlaybackController( + item = item, + nextState = nextState, + player = player, + controllerViewState = controllerViewState, + showPlay = showPlay, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBack = seekBack, + skipBackOnResume = skipBackOnResume, + seekForward = seekForward, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = onClickPlaybackDialogType, + onSeekBarChange = { + onSeekBarChange(it) + seekProgressMs = it + }, + currentSegment = currentSegment, + onChangeState = onChangeState, + modifier = + Modifier + .padding(bottom = 8.dp) + .onGloballyPositioned { + controllerHeight = with(density) { it.size.height.toDp() } + }, + seekBarInteractionSource = seekBarInteractionSource, + ) + } + + OverlayViewState.CHAPTERS -> { + if (chapters.isNotEmpty()) { + ChapterRowOverlay( + player = player, + controllerViewState = controllerViewState, + chapters = chapters, + hasNext = nextEnabled, + onChangeState = onChangeState, + modifier = + Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + ) + } + } + + OverlayViewState.QUEUE -> { + if (nextEnabled) { + QueueRowOverlay( + queue = queue, + controllerViewState = controllerViewState, + nextState = + remember(chapters) { + if (chapters.isNotEmpty()) { + OverlayViewState.CHAPTERS + } else { + OverlayViewState.CONTROLLER + } + }, + onChangeState = onChangeState, + onClickPlaylist = onClickPlaylist, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), + ) + } + } + } + } + + 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(), + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .fillMaxWidth(.95f), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent( + xPercentage = seekProgressPercent.coerceIn(0f, 1f), + ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + ) { + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + ) + } + } + } + } + + // Top + val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) + AnimatedVisibility( + visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(), + modifier = + Modifier + .align(Alignment.TopStart), + ) { + AsyncImage( + model = logoImageUrl, + contentDescription = "Logo", + alignment = Alignment.TopStart, + modifier = + Modifier + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } + AnimatedVisibility( + visible = !showDebugInfo && showClock && controllerViewState.controlsVisible, + enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(), + exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(), + modifier = + Modifier + .align(Alignment.TopEnd), + ) { + TimeDisplay() + } + + // Debug overlay + AnimatedVisibility( + visible = showDebugInfo && controllerViewState.controlsVisible, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + modifier = + Modifier + .align(Alignment.TopStart), + ) { + val configuration = LocalConfiguration.current + val height = + remember( + configuration, + controllerHeight, + ) { configuration.screenHeightDp.dp - controllerHeight } + PlaybackDebugOverlay( + analyticsState = analyticsState, + currentPlayback = currentPlayback, + modifier = + Modifier + .align(Alignment.TopStart) + .heightIn(max = height) + .padding(bottom = 16.dp) + .background(AppColors.TransparentBlack50) + .padding(8.dp) + .onFocusChanged { + if (it.hasFocus) { + // If Debug overlay gains focus, do not hide the controls + controllerViewState.pulseControls(Long.MAX_VALUE) + } else { + controllerViewState.pulseControls() + } + }, + ) + } + } +} + +/** + * The view state of the overlay + */ +enum class OverlayViewState { + HIDDEN, + CONTROLLER, + CHAPTERS, + QUEUE, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt new file mode 100644 index 00000000..e48fbb97 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt @@ -0,0 +1,229 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +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.fillMaxWidth +import androidx.compose.foundation.layout.size +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.Check +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.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.util.TrackSupport + +/** + * Debug info about the current playback tracks + */ +@Composable +fun PlaybackTrackInfo( + trackSupport: List<TrackSupport>, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface), +) { + val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } } + val selectedWeight = .5f + val weights = listOf(.25f, .4f, .5f, 1f, 1f) + + var expanded by remember { mutableStateOf(false) } + val focusRequesters = + remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } } + val showButtonFocusRequester = remember { FocusRequester() } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.SpaceBetween, + ) { + LazyColumn( + modifier = Modifier.weight(1f, fill = false), + ) { + item { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier, + ) { + val texts = + listOf( + "ID", + "Type", + "Codec", + "Supported", + "Labels", + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(selectedWeight), + ) { + Text( + text = "Selected", + style = textStyle, + ) + } + texts.forEachIndexed { index, text -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(weights[index]), + ) { + Text( + text = text, + style = textStyle, + ) + } + } + } + } + val tracks = if (expanded) trackSupport else selectedTracks + itemsIndexed(tracks) { index, track -> + ProvideTextStyle(textStyle) { + TrackSupportRow( + track = track, + selectedWeight = selectedWeight, + weights = weights, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == tracks.lastIndex, + Modifier.focusProperties { + down = showButtonFocusRequester + right = showButtonFocusRequester + }, + ), + ) + } + } + } + if (trackSupport.size > selectedTracks.size) { + val density = LocalDensity.current + val height = + remember(density, textStyle) { + with(density) { + textStyle.fontSize.toDp() * 2 + } + } + Box(Modifier.fillMaxWidth()) { + Button( + onClick = { expanded = !expanded }, + shape = + ClickableSurfaceDefaults.shape( + shape = RoundedCornerShape(25), + ), + contentHeight = height, + modifier = + Modifier + .align(Alignment.CenterEnd) + .focusRequester(showButtonFocusRequester) + .focusProperties { + up = focusRequesters[0] + }, + ) { + val text = + if (expanded) { + stringResource(R.string.hide) + } else { + stringResource( + R.string.show_more, + trackSupport.size - selectedTracks.size, + ) + } + Text( + text = text, + fontSize = textStyle.fontSize, + ) + } + } + } + } +} + +@Composable +fun TrackSupportRow( + track: TrackSupport, + selectedWeight: Float, + weights: List<Float>, + modifier: Modifier = Modifier, +) { + Surface( + onClick = {}, + modifier = modifier, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + shape = ClickableSurfaceDefaults.shape(RectangleShape), + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f), + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.ifElse( + track.selected, + Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)), + ), + ) { + val texts = + listOf( + track.id ?: "", + track.type.name, + track.codecs ?: "", + track.supported.name, + track.labels.joinToString(", "), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(selectedWeight), + ) { + if (track.selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(12.dp), + ) + } else { + Text( + text = "-", + ) + } + } + texts.forEachIndexed { index, text -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(weights[index]), + ) { + Text( + text = text, + ) + } + } + } + } +} 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 new file mode 100644 index 00000000..92b7afd1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -0,0 +1,98 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +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.focusRequester +import androidx.compose.ui.focus.focusRestorer +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.data.model.BaseItem +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 +import com.github.damontecres.wholphin.ui.playback.ControllerViewState +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun QueueRowOverlay( + queue: List<PlaylistItem>, + controllerViewState: ControllerViewState, + nextState: OverlayViewState, + onChangeState: (OverlayViewState) -> Unit, + onClickPlaylist: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + HiddenFocusBox(hiddenFocusRequester) { + onChangeState.invoke(nextState) + } + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = + PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }, + ) { + itemsIndexed(queue) { index, item -> + val interactionSource = + remember { MutableInteractionSource() } + val isFocused = + interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + SeasonCard( + item = item.item, + onClick = { + onClickPlaylist.invoke(item.item) + controllerViewState.hideControls() + }, + onLongClick = {}, + imageHeight = 140.dp, + interactionSource = interactionSource, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt similarity index 55% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index d1db4144..d2aae720 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback +package com.github.damontecres.wholphin.ui.playback.overlay /* * Modified from https://github.com/android/tv-samples @@ -18,7 +18,6 @@ package com.github.damontecres.wholphin.ui.playback * limitations under the License. */ -import android.view.KeyEvent import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.focusable @@ -46,10 +45,19 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.handleDPadKeyEvents +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 +/** + * This is a seek bar which seeks by a percentage of the duration instead of a fixed amount of time + * + * For example, if [intervals] is 10, then each seek will be 10% of total media duration + */ @Composable fun SteppedSeekBarImpl( progress: Float, @@ -80,15 +88,16 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) + seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) + seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -97,6 +106,33 @@ 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 + */ @OptIn(FlowPreview::class) @Composable fun IntervalSeekBarImpl( @@ -126,16 +162,19 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) + seekPositionMs = + (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds * multiplier) + .coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -144,12 +183,19 @@ fun IntervalSeekBarImpl( ) } +/** + * Actually renders the seek bar. It has callbacks for when the user moves to the left or right + * + * @see IntervalSeekBarImpl + * @see SteppedSeekBarImpl + */ @Composable -fun SeekBarDisplay( +private fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - onLeft: () -> Unit, - onRight: () -> Unit, + durationMs: Long, + onLeft: (Int) -> Unit, + onRight: (Int) -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -158,14 +204,13 @@ fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() + var leftHandledByRepeat by remember { mutableStateOf(false) } + var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { Canvas( modifier = Modifier @@ -173,24 +218,70 @@ fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - val trigger = - event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 - when (event.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - if (trigger) onLeft.invoke() - return@onPreviewKeyEvent true - } - - KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - if (trigger) onRight.invoke() - return@onPreviewKeyEvent true - } + if (!isDpadLeft(event) && !isDpadRight(event)) { + Timber.v("Ignoring %s", event) + return@onPreviewKeyEvent false } - false - }.handleDPadKeyEvents( - onLeft = onLeft, - onRight = onRight, - ).focusable(enabled = enabled, interactionSource = interactionSource), + 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 + } + } + + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) + } + leftHandledByRepeat = false + } + + 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 + } + } + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } + return@onPreviewKeyEvent true + } + }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( @@ -216,7 +307,6 @@ 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, ), @@ -226,7 +316,11 @@ 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/SeekBarState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt similarity index 96% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt index 3de5dca7..16e95d15 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback +package com.github.damontecres.wholphin.ui.playback.overlay import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt similarity index 87% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt index 08fb7685..35600342 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt @@ -1,5 +1,6 @@ -package com.github.damontecres.wholphin.ui.playback +package com.github.damontecres.wholphin.ui.playback.overlay +import androidx.annotation.FloatRange import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -52,20 +53,21 @@ fun Modifier.offsetByPercent( * * @param xPercentage percent offset between 0 inclusive and 1 inclusive */ -fun Modifier.offsetByPercent(xPercentage: Float) = - this.then( - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative( - x = - ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) - .coerceIn(0, constraints.maxWidth - placeable.width), - y = 0, - ) - } - }, - ) +fun Modifier.offsetByPercent( + @FloatRange(0.0, 1.0) xPercentage: Float, +) = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = + ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) + .coerceIn(0, constraints.maxWidth - placeable.width), + y = 0, + ) + } + }, +) /** * Show trickplay preview image. This composable assumes the provided URL is for the correct index. @@ -105,7 +107,7 @@ fun SeekPreviewImage( val tileIndex = index % numberOfTilesPerImage // Index of tile within the current image val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid - val y = (tileIndex / trickPlayInfo.tileHeight) // y position + val y = (tileIndex / trickPlayInfo.tileWidth) // y position Box( modifier = modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt similarity index 93% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt index 3e417a91..3ac39bfa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback +package com.github.damontecres.wholphin.ui.playback.overlay import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -25,6 +25,9 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse import kotlin.math.abs +/** + * Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs]. + */ @Composable fun SkipIndicator( durationMs: Long, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt new file mode 100644 index 00000000..f4a14e26 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt @@ -0,0 +1,92 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +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.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.skipStringRes +import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import org.jellyfin.sdk.model.api.MediaSegmentType + +@Composable +fun SkipSegmentButton( + type: MediaSegmentType, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val infiniteTransition = rememberInfiniteTransition("SkipSegmentButton") + val color by infiniteTransition.animateColor( + initialValue = MaterialTheme.colorScheme.onSurface, + targetValue = Color.Transparent, + animationSpec = + InfiniteRepeatableSpec( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "SkipSegmentButton_color", + ) + Button( + onClick = onClick, + modifier = + modifier.drawBehind { + val px = 6.dp.toPx() + drawRoundRect( + color = color, + cornerRadius = CornerRadius(this.size.width * .5f), + topLeft = Offset(-px, -px), + size = Size(this.size.width + px * 2, this.size.height + px * 2), + ) + }, + onLongClick = onLongClick, + enabled = true, + contentPadding = + PaddingValues( + start = 8.dp, + top = 4.dp, + end = 8.dp, + bottom = 4.dp, + ), + contentHeight = 32.dp, + interactionSource = interactionSource, + content = { + Text(text = stringResource(type.skipStringRes)) + }, + ) +} + +@PreviewTvSpec +@Composable +fun SkipSegmentButtonPreview() { + WholphinTheme { + val source = remember { PreviewInteractionSource() } + SkipSegmentButton( + type = MediaSegmentType.INTRO, + onClick = {}, + modifier = Modifier.padding(16.dp), + interactionSource = source, + ) + } +} 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/ChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt new file mode 100644 index 00000000..b21479c8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt @@ -0,0 +1,69 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Modifier +import androidx.tv.material3.Text +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.SelectedLeadingContent + +@Composable +fun <T> ChoicePreference( + title: String, + summary: String?, + possibleValues: List<T>, + selectedIndex: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, + subtitleDisplay: (index: Int, item: T) -> @Composable (() -> Unit)? = { _, _ -> null }, +) { + var dialogParams by remember { mutableStateOf<DialogParams?>(null) } + ClickPreference( + title = title, + summary = summary, + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + possibleValues.mapIndexed { index, item -> + DialogItem( + headlineContent = { valueDisplay.invoke(index, item) }, + leadingContent = { + SelectedLeadingContent(index == selectedIndex) + }, + supportingContent = subtitleDisplay.invoke(index, item), + onClick = { + onValueChange.invoke(index) + dialogParams = null + }, + ) + }, + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) + AnimatedVisibility(dialogParams != null) { + dialogParams?.let { + DialogPopup( + showDialog = true, + title = it.title, + dialogItems = it.items, + onDismissRequest = { dialogParams = null }, + waitToLoad = false, + dismissOnClick = false, + ) + } + } +} 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 9b9bcf9c..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 @@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue @@ -23,7 +21,6 @@ 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.unit.dp -import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -35,7 +32,6 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppStringPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference -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.isNotNullOrBlank @@ -43,7 +39,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import java.io.File -import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -182,51 +177,29 @@ fun <T> ComposablePreference( .valueToIndex(value as T) .let { values[it] } val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) } - ClickPreference( + ChoicePreference( title = title, summary = summary, - onClick = { - dialogParams = - DialogParams( - title = title, - fromLongClick = false, - items = - values.mapIndexed { index, it -> - DialogItem( - headlineContent = { - Text(it) - }, - leadingContent = { - if (index == selectedIndex) { - Icon( - imageVector = Icons.Default.Done, - contentDescription = "selected", - ) - } - }, - supportingContent = { - subtitles?.let { - val text = subtitles[index] - if (text.isNotNullOrBlank()) { - Text(text) - } - } - }, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - }, - ) + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + onValueChange(preference.indexToValue(index)) }, - interactionSource = interactionSource, modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + subtitles?.getOrNull(index)?.takeIf { it.isNotNullOrBlank() }?.let { + { + Text(text = it) + } + } + }, ) } is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues).toSortedSet() + val values = stringArrayResource(preference.displayValues) + val subtitles = preference.displayValuesSubtitles?.let { stringArrayResource(it) } val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -237,12 +210,18 @@ fun <T> ComposablePreference( list } MultiChoicePreference( - possibleValues = values as SortedSet<Any>, + possibleValues = preference.allValues, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(selectedValues.toList() as T) + onValueChange.invoke(it.toList() as T) + }, + 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 a4686b10..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 @@ -19,12 +19,13 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun <T> MultiChoicePreference( title: String, summary: String?, - possibleValues: Set<T>, + possibleValues: List<T>, selectedValues: Set<T>, onValueChange: (List<T>) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, + 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 = @@ -59,7 +60,8 @@ fun <T> MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(item) }, + 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 new file mode 100644 index 00000000..7deeb95c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -0,0 +1,376 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.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 +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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +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.services.BackdropService +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.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.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +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.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class NavDrawerPin( + val id: String, + val title: String, + val pinned: Boolean, + val item: NavDrawerItem, +) { + companion object { + fun create( + context: Context, + items: Map<NavDrawerItem, Boolean>, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +private fun <T> List<T>.move( + direction: MoveDirection, + index: Int, +): List<T> = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + +@Composable +fun NavDrawerPreference( + title: String, + summary: String?, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + viewModel: NavDrawerPreferencesViewModel = hiltViewModel(), +) { + val items by viewModel.state.collectAsState() + var showDialog by remember { mutableStateOf(false) } + ClickPreference( + title = title, + summary = summary, + onClick = { showDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDialog) { + NavDrawerPreferenceDialog( + items = items, + onDismissRequest = { + viewModel.save() + showDialog = false + }, + onClick = { index -> + val newItems = + items.toMutableList().apply { + set(index, items[index].let { it.copy(pinned = !it.pinned) }) + } + viewModel.update(newItems) + }, + onMoveUp = { index -> + viewModel.update(items.move(MoveDirection.UP, index)) + }, + onMoveDown = { index -> + viewModel.update(items.move(MoveDirection.DOWN, index)) + }, + ) + } +} + +@Composable +fun NavDrawerPreferenceDialog( + items: List<NavDrawerPin>, + onDismissRequest: () -> Unit, + onClick: (Int) -> Unit, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, +) { + val scope = rememberCoroutineScope() + val bringIntoViewRequesters = remember { List(items.size) { BringIntoViewRequester() } } + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + Column( + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.nav_drawer_pins), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + 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), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + NavDrawerPreferenceListItem( + title = item.title, + pinned = item.pinned, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClick = { onClick.invoke(index) }, + onMoveUp = { + onMoveUp.invoke(index) + ensureVisible(index - 1) + }, + onMoveDown = { + onMoveDown.invoke(index) + ensureVisible(index + 1) + }, + modifier = + Modifier + .animateItem() + .bringIntoViewRequester(bringIntoViewRequesters[index]), + ) + } + } + } + } +} + +@Composable +fun NavDrawerPreferenceListItem( + title: String, + pinned: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp), + ) { + ListItem( + selected = false, + headlineContent = { + Text( + text = title, + ) + }, + trailingContent = { + Switch( + checked = pinned, + onCheckedChange = { + onClick.invoke() + }, + ) + }, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) + MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) + } + } + } +} + +@Composable +fun MoveButton( + @StringRes icon: Int, + enabled: Boolean, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp), +) { + Text( + text = stringResource(icon), + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewTvSpec +@Composable +fun NavDrawerPreferenceListItemPreview() { + WholphinTheme { + NavDrawerPreferenceListItem( + title = "Movies", + pinned = true, + moveUpAllowed = true, + moveDownAllowed = true, + onClick = {}, + onMoveUp = {}, + onMoveDown = { }, + modifier = Modifier.width(360.dp), + ) + } +} + +@HiltViewModel +class NavDrawerPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) : ViewModel() { + val state = MutableStateFlow<List<NavDrawerPin>>(listOf()) + + init { + viewModelScope.launchDefault { + val state = navDrawerService.state.value + val user = serverRepository.currentUser + val seerr = seerrServerRepository.active.firstOrNull() + if (user == null || seerr == null) { + return@launchDefault + } + val navDrawerPins = + withContext(WholphinDispatchers.IO) { + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + } + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + this@NavDrawerPreferencesViewModel.state.value = pins + } + } + + fun update(items: List<NavDrawerPin>) { + state.update { items } + } + + fun save() { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser?.let { user -> + serverRepository.currentUserDto?.let { userDto -> + if (user.id == userDto.id) { + val toSave = + state.value.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + 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 c9f6f529..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 @@ -35,9 +42,56 @@ enum class PreferenceScreenOption { ADVANCED, EXO_PLAYER, 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 dcbda2fc..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 @@ -6,23 +6,26 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Image 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.Row 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.layout.width 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.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -33,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 @@ -45,29 +50,47 @@ 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 +import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +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.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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 +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 timber.log.Timber +import java.util.Locale @Composable fun PreferencesContent( @@ -80,19 +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) + } - val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } - val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + 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 { @@ -108,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) { @@ -125,6 +163,10 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences + PreferenceScreenOption.SCREENSAVER -> screensaverPreferences + PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences + PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences + PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -132,6 +174,10 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options 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) } @@ -146,7 +192,7 @@ fun PreferencesContent( try { System.loadLibrary("mpv") System.loadLibrary("player") - } catch (ex: Exception) { + } catch (ex: UnsatisfiedLinkError) { Timber.w(ex, "Could not load libmpv") showToast(context, "MPV is not supported on this device") viewModel.preferenceDataStore.updateData { @@ -156,6 +202,12 @@ fun PreferencesContent( } } + val showUpdate = + UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -165,291 +217,492 @@ fun PreferencesContent( 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 = stringResource(screenTitle), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable + Text( + text = stringResource(screenTitle), + 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.fillMaxSize(), ) { - item { - val updateFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (focusedIndex.first == 0 && focusedIndex.second == 0) { - // Only re-focus if the user hasn't moved - updateFocusRequester.tryRequestFocus() - } + serverVersionMessage?.let { message -> + item { + ListItem( + selected = false, + enabled = true, + onClick = {}, + headlineContent = { + PreferenceTitle(message, color = MaterialTheme.colorScheme.error) + }, + modifier = Modifier, + ) } - ClickPreference( - title = stringResource(R.string.install_update), - onClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = release?.version?.toString(), - modifier = - Modifier - .focusRequester(updateFocusRequester) - .playSoundOnFocus(movementSounds), - ) } - } - 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<AppPreferences, Any> + if (showUpdate) { item { val interactionSource = remember { MutableInteractionSource() } val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - if (movementSounds) playOnClickSound(context) - onFocus.invoke(groupIndex, prefIndex) + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() } } - when (pref) { - AppPreference.InstalledVersion -> { - var clickCount by remember { mutableIntStateOf(0) } - ClickPreference( - title = stringResource(R.string.installed_version), - onClick = { - if (movementSounds) playOnClickSound(context) - if (clickCount++ >= 2) { - clickCount = 0 - viewModel.navigationManager.navigateTo(Destination.Debug) - } - }, - summary = installedVersion.toString(), - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = release?.version?.toString(), + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(updateFocusRequester) + .focusRequester(firstFocusRequester) + .playSoundOnFocus(movementSounds) + .lazyListWrapScrolling( + state = state, + focused = focused, + isFirst = true, + isLast = false, + firstFocusRequester = firstFocusRequester, + lastFocusRequester = lastFocusRequester, + ), + ) + } + } - AppPreference.Update -> { - ClickPreference( - title = - if (release != null && updateAvailable) { - stringResource(R.string.install_update) - } else if (!preferences.autoCheckForUpdates && release == null) { - stringResource(R.string.check_for_updates) - } else { - stringResource(R.string.no_update_available) - }, - onClick = { - if (movementSounds) playOnClickSound(context) - if (release != null && updateAvailable) { - release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - } - } else { - updateVM.init(preferences.updateUrl) - } - }, - onLongClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = - if (updateAvailable) { - release?.version?.toString() - } else { - null - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } - - AppPreference.ClearImageCache -> { - val summary = - remember(cacheUsage) { - cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT - val memoryUsedMB = - it.imageMemoryUsed / AppPreference.MEGA_BIT - val memoryMaxMB = - it.imageMemoryMax / AppPreference.MEGA_BIT - "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" - } - } - ClickPreference( - title = stringResource(pref.title), - onClick = { - SingletonImageLoader.get(context).let { - it.memoryCache?.clear() - it.diskCache?.clear() - updateCache = true - } - }, - modifier = Modifier, - summary = summary, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.UserPinnedNavDrawerItems -> { - val selectedItems = - navDrawerPins.keys.mapNotNull { - if (navDrawerPins[it] ?: false) it else null - } - MultiChoicePreference( - title = stringResource(pref.title), - summary = pref.summary(context, null), - possibleValues = navDrawerPins.keys, - selectedValues = selectedItems.toSet(), - onValueChange = { newSelectedItems -> - viewModel.updatePins(newSelectedItems) - }, - ) { - Text(it.name(context)) + 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), + ) + if (group.title == R.string.about) { + WolfskinAbout() + } + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .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) + if (movementSounds) playOnClickSound(context) + onFocus.invoke(groupIndex, prefIndex) } } - - AppPreference.SendAppLogs -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.sendAppLogs() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.resetSubtitleSettings() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.RequireProfilePin -> { - SwitchPreference( - title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true - }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, - ) - } - - AppPreference.SeerrIntegration -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - if (seerrIntegrationEnabled) { - seerrDialogMode = SeerrDialogMode.Remove - } else { - seerrVm.resetStatus() - seerrDialogMode = SeerrDialogMode.Add - } - }, - modifier = Modifier, - summary = - if (seerrIntegrationEnabled) { - stringResource(R.string.enabled) - } else { - null + when (pref) { + AppPreference.InstalledVersion -> { + ClickPreference( + title = stringResource(R.string.installed_version), + onClick = { + showVersionDialog = true }, - onLongClick = {}, - interactionSource = interactionSource, - ) - } + onLongClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + summary = installedVersion.toString(), + interactionSource = interactionSource, + modifier = focusModifier, + ) + } - 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() + AppPreference.Update -> { + ClickPreference( + title = + if (release != null && updateAvailable) { + stringResource(R.string.install_update) + } else if (!preferences.autoCheckForUpdates && release == null) { + stringResource(R.string.check_for_updates) + } else { + stringResource(R.string.no_update_available) + }, + onClick = { + if (movementSounds) playOnClickSound(context) + if (release != null && updateAvailable) { + release?.let { + viewModel.navigationManager.navigateTo( + Destination.UpdateApp, + ) + } + } else { + updateVM.init() } + }, + onLongClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = + if (updateAvailable) { + release?.version?.toString() + } else { + null + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } - PreferenceValidation.Valid -> { + AppPreference.ClearImageCache -> { + val summary = + remember(cacheUsage) { + cacheUsage.let { + val diskMB = + it.imageDiskUsed / AppPreference.MEGA_BIT + val memoryUsedMB = + it.imageMemoryUsed / AppPreference.MEGA_BIT + val memoryMaxMB = + it.imageMemoryMax / AppPreference.MEGA_BIT + "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" + } + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + updateCache = true + } + }, + modifier = focusModifier, + summary = summary, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.UserPinnedNavDrawerItems -> { + NavDrawerPreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + modifier = focusModifier, + interactionSource = interactionSource, + ) + } + + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = focusModifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.resetSubtitleSettings() + }, + modifier = focusModifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + 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), + 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) + } + } + } + }, + modifier = focusModifier, + ) + } + + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + seerrDialogMode = + when (val conn = seerrConnection.connection) { + is SeerrConnectionStatus.Error -> { + SeerrDialogMode.Error( + conn.server, + conn.user, + conn.ex, + ) + } + + SeerrConnectionStatus.NotConfigured -> { + SeerrDialogMode.Add + } + + is SeerrConnectionStatus.Success -> { + SeerrDialogMode.Remove( + conn.current, + ) + } + } + }, + modifier = focusModifier, + summary = + when (seerrConnection.connection) { + is SeerrConnectionStatus.Error -> { + stringResource(R.string.voice_error_server) + } + + SeerrConnectionStatus.NotConfigured -> { + stringResource(R.string.add_server) + } + + is SeerrConnectionStatus.Success -> { + stringResource(R.string.enabled) + } + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.QuickConnect -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (currentUser != null) { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = true + } + }, + modifier = focusModifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + ScreensaverPreference.Start -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.screensaverService.start() + }, + modifier = focusModifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.ExternalPlayerApp -> { + val value = pref.getter.invoke(preferences).toString() + val selectedIndex = + remember(value, players) { + players.indexOfFirstOrNull { it.identifier == value } + } ?: 0 + ChoicePreference( + title = stringResource(pref.title), + summary = players[selectedIndex].name, + interactionSource = interactionSource, + possibleValues = players, + selectedIndex = selectedIndex, + onValueChange = { index -> + scope.launch(ExceptionHandler()) { + val newValue = + players.getOrNull(index)?.identifier ?: "" + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter.invoke(prefs, newValue) + } + } + }, + valueDisplay = { index, item -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (item.icon != null) { + Image( + bitmap = item.icon, + contentDescription = null, + modifier = Modifier.width(40.dp), + ) + } + 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(prefs, newValue) + pref.setter.invoke(prefs, false) } } + } else { + showConfirm = true } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + 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( + 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()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } + } + } + } + }, + interactionSource = interactionSource, + modifier = focusModifier, + ) + } } } } @@ -472,40 +725,77 @@ fun PreferencesContent( ) } } - when (seerrDialogMode) { - SeerrDialogMode.Remove -> { - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = currentServer?.url ?: "", - 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 + 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) + + LaunchedEffect(quickConnectStatus) { + when (val status = quickConnectStatus) { + LoadingState.Success -> { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + showQuickConnectDialog = false + } + + is LoadingState.Error -> { + val errorMessage = status.message ?: "Authorization failed" + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + } + + else -> {} + } + } + + QuickConnectDialog( + onSubmit = { code -> + viewModel.authorizeQuickConnect(code) + }, + onDismissRequest = { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = false + }, + ) + } + if (showVersionDialog) { + LaunchedEffect(Unit) { + viewModel.fetchReleaseNotes() + } + val release by viewModel.releaseNotes.collectAsState() + ScrollableDialog( + onDismissRequest = { showVersionDialog = false }, + ) { + item { + when (val r = release) { + is DataLoadingState.Error -> { + ErrorMessage(message = "Error", exception = r.exception) + } + + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage() + } + + is DataLoadingState.Success<Release> -> { + ReleaseNotes(r.data) } } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) } - - SeerrDialogMode.None -> {} } } + if (showLocaleChoiceDialog) { + LocaleChoiceDialog( + onDismissRequest = { showLocaleChoiceDialog = false }, + ) + } } @Composable @@ -522,6 +812,10 @@ fun PreferencesPage( PreferenceScreenOption.ADVANCED, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, + PreferenceScreenOption.SCREENSAVER, + PreferenceScreenOption.SKIP_SEGMENTS, + PreferenceScreenOption.EXPERIMENTAL, + PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, @@ -541,11 +835,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed class SeerrDialogMode { - data object None : SeerrDialogMode() - - data object Add : SeerrDialogMode() - - data object Remove : 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 9973bbec..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 @@ -1,36 +1,45 @@ package com.github.damontecres.wholphin.ui.preferences +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.NavDrawerItemRepository -import com.github.damontecres.wholphin.data.ServerPreferencesDao +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.isPinned import com.github.damontecres.wholphin.data.model.JellyfinUser -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.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService 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.ui.detail.DebugViewModel.Companion.sendAppLogs +import com.github.damontecres.wholphin.services.ServerReportService +import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain +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.RememberTabManager +import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.combine +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 @HiltViewModel @@ -42,70 +51,70 @@ class PreferencesViewModel val preferenceDataStore: DataStore<AppPreferences>, val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, + val screensaverService: ScreensaverService, private val serverRepository: ServerRepository, - private val navDrawerItemRepository: NavDrawerItemRepository, - private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, - private val deviceInfo: DeviceInfo, - private val clientInfo: ClientInfo, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - private lateinit var allNavDrawerItems: List<NavDrawerItem> - val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf()) + private val updateChecker: UpdateChecker, + private val serverReportService: ServerReportService, + ) : ViewModel() { + val currentUser = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) - val currentUser get() = serverRepository.currentUser + val seerrConnection = seerrServerRepository.connection - val seerrEnabled = - seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> - seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId - } + private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) + val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus + + val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending) + + 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 { - serverRepository.currentUser.value?.let { user -> - allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) - } - } - } - - fun updatePins(newSelectedItems: List<NavDrawerItem>) { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf<NavDrawerItem>().apply { - addAll(allNavDrawerItems) - removeAll(newSelectedItems) - } - val enabledItems = newSelectedItems.toSet() - val toSave = - disabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.UNPINNED, - ) - } + - enabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.PINNED, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) - } + val fakeIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") + } + val externalPlayers = getExternalPlayers(context) + val systemDefault = + ExternalPlayerApp( + name = context.getString(R.string.system_default), + icon = null, + identifier = "", + ) + this@PreferencesViewModel.externalPlayers.update { listOf(systemDefault) + externalPlayers } } } fun sendAppLogs() { - sendAppLogs(context, api, clientInfo, deviceInfo) + viewModelScope.launchIO { serverReportService.sendAppLogs() } } fun resetSubtitleSettings() { @@ -119,7 +128,57 @@ 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) + } + } + + fun resetQuickConnectStatus() { + _quickConnectStatus.value = LoadingState.Pending + } + + fun authorizeQuickConnect(code: String) { + viewModelScope.launchIO { + _quickConnectStatus.value = LoadingState.Loading + try { + val success = serverRepository.authorizeQuickConnect(code) + _quickConnectStatus.value = + if (success) { + LoadingState.Success + } else { + LoadingState.Error("Authorization failed") + } + } catch (e: Exception) { + _quickConnectStatus.value = LoadingState.Error(e) + } + } + } + + fun fetchReleaseNotes() { + viewModelScope.launchIO { + releaseNotes.update { DataLoadingState.Loading } + try { + val release = updateChecker.getRelease(updateChecker.getInstalledVersion()) + if (release != null) { + releaseNotes.update { DataLoadingState.Success(release) } + } else { + releaseNotes.update { DataLoadingState.Error("Release not found") } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching release") + releaseNotes.update { DataLoadingState.Error(ex) } + } } } @@ -133,3 +192,37 @@ class PreferencesViewModel } } } + +data class ExternalPlayerApp( + val name: String, + val icon: ImageBitmap?, + val identifier: String, +) + +fun getExternalPlayers(context: Context): List<ExternalPlayerApp> { + val fakeIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") + } + val externalPlayers = + context.packageManager + .queryIntentActivities(fakeIntent, PackageManager.MATCH_ALL) + .filter { it.priority >= 0 } + .map { + val component = + ComponentName( + it.activityInfo.packageName, + it.activityInfo.name, + ) + ExternalPlayerApp( + name = it.loadLabel(context.packageManager).toString(), + icon = + it + .loadIcon(context.packageManager) + .toBitmap() + .asImageBitmap(), + identifier = component.flattenToString(), + ) + } + return externalPlayers +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt new file mode 100644 index 00000000..dfad49b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.wholphin.ui.preferences + +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +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.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.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton + +@Composable +fun QuickConnectDialog( + onSubmit: (String) -> Unit, + onDismissRequest: () -> Unit, + elevation: Dp = 3.dp, +) { + var code by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + + val isValidCode: (String) -> Boolean = { value -> + val trimmed = value.trim() + trimmed.length == 6 && trimmed.all { it.isDigit() } + } + + val onSubmitCode = { + if (isValidCode(code)) { + showError = false + onSubmit(code.trim()) + } else { + showError = true + } + } + + Dialog( + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + onDismissRequest = onDismissRequest, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(16.dp) + .width(360.dp) + .background( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), + shape = RoundedCornerShape(8.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.quick_connect_code), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + EditTextBox( + value = code, + onValueChange = { + code = it + showError = false + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + onSubmitCode() + }, + ), + isInputValid = { value -> + !showError || isValidCode(value) + }, + modifier = Modifier.fillMaxWidth(), + ) + + if (showError) { + Text( + text = stringResource(R.string.quick_connect_code_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + + TextButton( + stringRes = R.string.submit, + onClick = onSubmitCode, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } +} 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/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt new file mode 100644 index 00000000..17beee6c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt @@ -0,0 +1,304 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.activity.compose.BackHandler +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.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 +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.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.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.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 kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchForContent( + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchForViewModel = + hiltViewModel<SearchForViewModel, SearchForViewModel.Factory>( + key = searchType.serialName, + creationCallback = { + it.create(searchType) + }, + ), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + + var query by rememberSaveable { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + viewModel.search(searchType, searchQuery) + } + + LaunchedEffect(query) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(searchType, query) + } + } + } + + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + val title = titleRes?.let { stringResource(it) } ?: "" + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.search_for, title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + 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(searchFocusRequester), + ) { + 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 + } + }, + ) + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + SearchForResultsRow( + title = stringResource(R.string.results), + results = state.results, + onClick = onClick, + modifier = Modifier.focusRequester(focusRequester), + ) + } + item { + HorizontalDivider() + } + item { + SearchForResultsRow( + title = stringResource(R.string.suggestions), + results = state.recent, + onClick = onClick, + modifier = Modifier, + ) + } + } + } +} + +@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, + ) + }, + ) + } + } + } +} + +@Composable +fun SearchForDialog( + onDismissRequest: () -> Unit, + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + elevation = 3.dp, + ) { + SearchForContent( + searchType = searchType, + onClick = onClick, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(.8f) + .fillMaxHeight(.66f), + ) + } +} 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 cc6f2d3d..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,54 +49,65 @@ 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.Button 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.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() @@ -272,15 +285,7 @@ fun InstallUpdatePageContent( }, ) { item { - Markdown( - (release.notes.joinToString("\n\n") + (release.body ?: "")) - .replace( - Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"), - "#$1", - ) - // Remove the last line for full changelog since its just a link - .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""), - ) + ReleaseNotes(release) } } Column( @@ -317,6 +322,25 @@ fun InstallUpdatePageContent( } } +@Composable +fun ReleaseNotes( + release: Release, + modifier: Modifier = Modifier, +) { + Markdown( + content = release.content, + typography = + markdownTypography( + h1 = MaterialTheme.typography.headlineLarge, + h2 = MaterialTheme.typography.headlineMedium, + h3 = MaterialTheme.typography.headlineSmall, + text = MaterialTheme.typography.bodySmall, + code = MaterialTheme.typography.bodySmall, + ), + modifier = modifier, + ) +} + @Composable fun DownloadDialog( contentLength: Long, @@ -392,13 +416,15 @@ private fun InstallUpdatePageContentPreview() { downloadUrl = "https://url", publishedAt = null, body = - "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + + "## Header 2\n" + + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + "Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " + "Iaculis massa nisl malesuada lacinia integer nunc posuere. " + "Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " + "torquent per conubia nostra inceptos himenaeos.\n\n" + + "### Header 3\n" + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + 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 e0d49631..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 @@ -25,6 +25,7 @@ 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.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -33,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 @@ -72,6 +72,16 @@ fun PinEntry( Key.DirectionRight -> "R" Key.DirectionDown -> "D" Key.DirectionLeft -> "L" + Key.Zero, Key.NumPad0 -> "0" + Key.One, Key.NumPad1 -> "1" + Key.Two, Key.NumPad2 -> "2" + Key.Three, Key.NumPad3 -> "3" + Key.Four, Key.NumPad4 -> "4" + Key.Five, Key.NumPad5 -> "5" + Key.Six, Key.NumPad6 -> "6" + Key.Seven, Key.NumPad7 -> "7" + Key.Eight, Key.NumPad8 -> "8" + Key.Nine, Key.NumPad9 -> "9" else -> return@onKeyEvent false } onTextChange.invoke(str) @@ -122,6 +132,16 @@ fun PinEntryCreate( Key.DirectionRight -> "R" Key.DirectionDown -> "D" Key.DirectionLeft -> "L" + Key.Zero, Key.NumPad0 -> "0" + Key.One, Key.NumPad1 -> "1" + Key.Two, Key.NumPad2 -> "2" + Key.Three, Key.NumPad3 -> "3" + Key.Four, Key.NumPad4 -> "4" + Key.Five, Key.NumPad5 -> "5" + Key.Six, Key.NumPad6 -> "6" + Key.Seven, Key.NumPad7 -> "7" + Key.Eight, Key.NumPad8 -> "8" + Key.Nine, Key.NumPad9 -> "9" else -> return@onKeyEvent false } onTextChange.invoke(str) @@ -154,6 +174,7 @@ fun PinEntryCreate( @Composable fun PinArrowRow(modifier: Modifier = Modifier) { Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { @@ -167,6 +188,12 @@ fun PinArrowRow(modifier: Modifier = Modifier) { fontFamily = FontAwesome, ) } + Text( + text = stringResource(R.string.pin_digits_hint), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) } } 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 b209ee59..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 @@ -9,43 +9,32 @@ 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.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.HorizontalDivider 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.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight 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.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon -import androidx.tv.material3.IconButtonDefaults -import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme 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 com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.PublicSystemInfo import java.util.UUID @@ -61,120 +50,6 @@ sealed interface ServerConnectionStatus { ) : ServerConnectionStatus } -/** - * Display a list of servers plus option to add a new one - */ -@Composable -fun ServerList( - servers: List<JellyfinServer>, - connectionStatus: Map<UUID, ServerConnectionStatus>, - onSwitchServer: (JellyfinServer) -> Unit, - onTestServer: (JellyfinServer) -> Unit, - onAddServer: () -> Unit, - onRemoveServer: (JellyfinServer) -> Unit, - allowAdd: Boolean, - allowDelete: Boolean, - modifier: Modifier = Modifier, -) { - var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } - - LazyColumn(modifier = modifier) { - items(servers) { server -> - val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) }, - supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) }, - leadingContent = { - when (status) { - is ServerConnectionStatus.Success -> {} - - ServerConnectionStatus.Pending -> { - CircularProgress( - Modifier.size(IconButtonDefaults.MediumIconSize), - ) - } - - is ServerConnectionStatus.Error -> { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = status.message, - tint = MaterialTheme.colorScheme.errorContainer, - ) - } - } - }, - onClick = { - when (status) { - is ServerConnectionStatus.Success -> { - onSwitchServer.invoke(server) - } - - ServerConnectionStatus.Pending -> {} - - is ServerConnectionStatus.Error -> { - onTestServer.invoke(server) - } - } - }, - onLongClick = { - if (allowDelete) { - showDeleteDialog = server - } - }, - modifier = Modifier, - ) - } - if (allowAdd) { - item { - HorizontalDivider() - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = stringResource(R.string.add_server)) }, - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - tint = Color.Green.copy(alpha = .8f), - contentDescription = null, - ) - }, - onClick = { onAddServer.invoke() }, - modifier = Modifier, - ) - } - } - } - showDeleteDialog?.let { server -> - DialogPopup( - showDialog = allowDelete, - title = server.name ?: server.url, - dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_servers), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchServer.invoke(server) - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveServer.invoke(server) - }, - ), - onDismissRequest = { showDeleteDialog = null }, - dismissOnClick = true, - waitToLoad = true, - properties = DialogProperties(), - elevation = 5.dp, - ) - } -} - /** * Generate a consistent color for a UUID */ @@ -223,6 +98,7 @@ fun rememberIdColor( @Composable fun ServerIconCard( server: JellyfinServer, + serverVersionSupported: ServerVersionSupported, connectionStatus: ServerConnectionStatus, isCurrentServer: Boolean, onClick: () -> Unit, @@ -292,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( @@ -307,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, - ) - } - } } } } @@ -382,7 +263,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( 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 4902ca06..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 @@ -20,8 +30,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete 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 @@ -30,11 +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 @@ -42,33 +57,57 @@ 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.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EditTextBox +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( modifier: Modifier = Modifier, viewModel: SwitchServerViewModel = hiltViewModel(), ) { - val servers by viewModel.servers.observeAsState(listOf()) - val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) - - var showAddServer by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) } - + val state by viewModel.state.collectAsState() LaunchedEffect(Unit) { viewModel.init() } + when (val st = state.loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage(modifier) + + LoadingState.Success -> SwitchServerContentInternal(state, viewModel, modifier) + } +} + +@Composable +private fun SwitchServerContentInternal( + state: SwitchServerState, + 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), ) { @@ -107,8 +146,16 @@ fun SwitchServerContent( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (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), @@ -119,16 +166,16 @@ fun SwitchServerContent( .focusRestorer(firstServerFocus) .focusRequester(focusRequester), ) { - itemsIndexed(servers) { index, server -> - val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending + itemsIndexed(state.servers) { index, server -> ServerIconCard( - server = server, - connectionStatus = status, + server = server.server, + connectionStatus = server.status, + serverVersionSupported = server.versionSupported, isCurrentServer = false, // TODO: Determine current server if needed onClick = { - when (status) { + when (server.status) { is ServerConnectionStatus.Success -> { - viewModel.switchServer(server) + viewModel.switchServer(server.server) } ServerConnectionStatus.Pending -> { @@ -136,21 +183,30 @@ fun SwitchServerContent( } is ServerConnectionStatus.Error -> { - viewModel.testServer(server) + viewModel.testServer(server.server) } } }, onLongClick = { - showDeleteDialog = server + showDeleteDialog = server.server }, allowDelete = true, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)), + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = index + }.focusRequester(focusRequesters[index]), ) } // Add Server card - always rightmost item { AddServerCard( onClick = { showAddServer = true }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) focusedIndex = -1 + }.focusRequester(focusRequesters.last()), ) } } @@ -165,6 +221,24 @@ fun SwitchServerContent( ) } + 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( @@ -197,190 +271,242 @@ fun SwitchServerContent( } 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 } + } - val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) + 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, + ) + } + } + } - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) - val filteredDiscoveredServers = - remember(discoveredServers) { - val seenUrls = mutableSetOf<String>() - 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 + 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() && 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 state by viewModel.addServerState.observeAsState(LoadingState.Pending) - 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 - .focusRequester(textBoxFocusRequester) - .fillMaxWidth(), - ) - when (val st = state) { - 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() && state == LoadingState.Pending, - modifier = Modifier, - ) { - if (state == 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 f9e7d0a3..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,35 +1,39 @@ 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.lifecycle.MutableLiveData +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 import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager 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.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +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.extensions.quickConnectApi +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 -import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -43,49 +47,85 @@ class SwitchServerViewModel val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, ) : ViewModel() { - val servers = MutableLiveData<List<JellyfinServer>>(listOf()) - val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) - val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf()) + private val _state = MutableStateFlow(SwitchServerState()) + val state: StateFlow<SwitchServerState> = _state - val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf()) - - val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending) + 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() { - addServerState.value = LoadingState.Pending + _state.update { it.copy(addServerState = LoadingState.Pending) } } fun init() { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = mapOf() - serverQuickConnect.value = mapOf() - } + _state.update { SwitchServerState() } val allServers = serverDao .getServers() .map { it.server } .sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url }) - withContext(Dispatchers.Main) { - servers.value = allServers + .map { ServerState(it) } + _state.update { + it.copy( + loading = LoadingState.Success, + servers = allServers, + ) } allServers.forEach { server -> - internalTestServer(server) + internalTestServer(server.server) } } } - fun testServer(server: JellyfinServer) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) + private fun updateServerState( + 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, + versionSupported = supported, + ), + ) + } + it.copy(servers = servers) + } + } + + fun testServer(server: JellyfinServer) { + updateServerState(server, ServerConnectionStatus.Pending) viewModelScope.launchIO { - delay(1000) + delay(500) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) @@ -95,58 +135,42 @@ class SwitchServerViewModel } } - private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus = - try { - val systemInfo = - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() - .content - val result = ServerConnectionStatus.Success(systemInfo) - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - result, - ) - } + private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus { + val result = + try { + val systemInfo = + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .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) } - result - } catch (ex: Exception) { - val status = ServerConnectionStatus.Error(ex.localizedMessage) - Timber.w(ex, "Error checking server ${server.url}") - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - status, - ) - } - } - status - } + updateServerState(server, result) + return result + } fun switchServer(server: JellyfinServer) { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) - } - } + updateServerState(server, ServerConnectionStatus.Pending) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { val updatedServer = @@ -155,17 +179,18 @@ class SwitchServerViewModel version = result.systemInfo.version, ) serverRepository.addAndChangeServer(updatedServer) - withContext(Dispatchers.Main) { - navigationManager.navigateTo(SetupDestination.UserList(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) { - addServerState.value = LoadingState.Loading + fun addServer( + inputUrl: String, + showToast: Boolean, + ) { + _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { val scores = @@ -187,27 +212,12 @@ class SwitchServerViewModel version = serverInfo.version, ) serverRepository.addAndChangeServer(server) - val quickConnect = - jellyfin - .createApi(serverUrl) - .quickConnectApi - .getQuickConnectEnabled() - .content - withContext(Dispatchers.Main) { - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(id, quickConnect) - } - } - withContext(Dispatchers.Main) { - addServerState.value = LoadingState.Success - navigationManager.navigateTo(SetupDestination.UserList(server)) - } + _state.update { it.copy(addServerState = LoadingState.Success) } + navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - withContext(Dispatchers.Main) { - addServerState.value = - 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) @@ -235,14 +245,13 @@ class SwitchServerViewModel "${it.address} - $issues" } val message = "Error, tried addresses:\n$errors" - addServerState.setValueOnMain(LoadingState.Error(message)) + _state.update { it.copy(addServerState = LoadingState.Error(message)) } + if (showToast) showToast(context, message) } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error(exception = ex) - } + _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } + if (showToast) showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -257,23 +266,48 @@ class SwitchServerViewModel fun discoverServers() { viewModelScope.launchIO { jellyfin.discovery.discoverLocalServers().collect { server -> - val newServerList = - discoveredServers.value!! - .toMutableList() - .apply { - add( - JellyfinServer( - server.id.toUUID(), - server.name, - server.address, - null, - ), - ) - } - withContext(Dispatchers.Main) { - discoveredServers.value = newServerList + val jellyfinServer = + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + null, + ) + _state.update { + it.copy( + discoveredServers = + it.discoveredServers + .toMutableList() + .apply { + add(jellyfinServer) + }.distinctBy { + // Filter out duplicates within the discovered servers list (same URL appearing multiple times) + it.url.lowercase().trim() + }, + ) } } } } } + +data class SwitchServerState( + val loading: LoadingState = LoadingState.Pending, + val servers: List<ServerState> = emptyList(), + val discoveredServers: List<JellyfinServer> = emptyList(), + val addServerState: LoadingState = LoadingState.Pending, +) + +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 53afb662..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 @@ -14,16 +14,18 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions 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.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.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 @@ -41,40 +43,70 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox +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.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 @Composable fun SwitchUserContent( - currentServer: JellyfinServer, + server: JellyfinServer, modifier: Modifier = Modifier, viewModel: SwitchUserViewModel = hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>( - creationCallback = { it.create(currentServer) }, + creationCallback = { it.create(server) }, ), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.init() } -// val currentServer by viewModel.serverRepository.currentServer.observeAsState() - val currentUser by viewModel.serverRepository.currentUser.observeAsState() - val users by viewModel.users.observeAsState(listOf()) + val state by viewModel.state.collectAsState() - val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) - val quickConnect by viewModel.quickConnectState.observeAsState(null) + val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null) var showAddUser by remember { mutableStateOf(false) } + var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) } + var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") } - val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) - val loginAttempts by viewModel.loginAttempts.observeAsState(0) - LaunchedEffect(userState) { + fun showAddUserDialog(user: JellyfinUser?) { + 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 = userState) { + when (val s = state.switchUserState) { is LoadingState.Error -> { val msg = s.message ?: s.exception?.localizedMessage Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() @@ -86,247 +118,278 @@ fun SwitchUserContent( } var switchUserWithPin by remember { mutableStateOf<JellyfinUser?>(null) } - currentServer?.let { server -> - Box( - modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(16.dp), + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Box( + modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.select_user), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = server.name ?: server.url, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - UserList( - users = users, - currentUser = currentUser, - onSwitchUser = { user -> - if (user.hasPin) { - switchUserWithPin = user - } else { - viewModel.switchUser(user) - } - }, - onAddUser = { showAddUser = true }, - onRemoveUser = { user -> - viewModel.removeUser(user) - }, - onSwitchServer = { - viewModel.setupNavigationManager.navigateTo( - SetupDestination.ServerList, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - if (showAddUser) { - var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } - LaunchedEffect(Unit) { - viewModel.clearSwitchUserState() - viewModel.resetAttempts() - if (useQuickConnect) { - viewModel.initiateQuickConnect(server) - } - } - BasicDialog( - onDismissRequest = { - viewModel.cancelQuickConnect() - showAddUser = false - }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .focusGroup() - .padding(16.dp) - .fillMaxWidth(.4f), + .fillMaxWidth() + .align(Alignment.Center) + .padding(16.dp), ) { - if (useQuickConnect) { - if (quickConnect == null && userState !is LoadingState.Error) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .height(32.dp) - .align(Alignment.CenterHorizontally), - ) { - CircularProgress(Modifier.size(20.dp)) - Text( - text = "Waiting for Quick Connect code...", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.select_user), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = server.name ?: server.url, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + UserList( + users = state.users, + currentUser = currentUser, + onSwitchUser = { user -> + if (user.accessToken == null || user.requireLogin) { + showAddUserDialog(user) + } else if (user.hasPin) { + switchUserWithPin = user + } else { + trySwitchUser(user) } - } else if (quickConnect != null) { + }, + onAddUser = { + showAddUserDialog(null) + }, + onRemoveUser = { user -> + viewModel.removeUser(user) + }, + onSwitchServer = { + viewModel.setupNavigationManager.navigateTo( + SetupDestination.ServerList, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + val resources = LocalResources.current + val message = + remember(resources, state.serverVersionSupported) { + resources.serverSupportedMessage( + state.serverVersionSupported, + state.serverVersion, + ) + } + SwitchErrorMessage(message) + } + } + } + + if (showAddUser) { + var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) } + LaunchedEffect(Unit) { + viewModel.clearSwitchUserState() + viewModel.resetAttempts() + if (useQuickConnect) { + viewModel.initiateQuickConnect(server, addUser) + } + } + BasicDialog( + onDismissRequest = { + viewModel.cancelQuickConnect() + hideAddUserDialog() + }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusGroup() + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (useQuickConnect) { + if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .height(32.dp) + .align(Alignment.CenterHorizontally), + ) { + CircularProgress(Modifier.size(20.dp)) Text( - text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", + text = "Waiting for Quick Connect code...", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = quickConnect?.code ?: "Failed to get code", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier, ) } - UserStateError(userState) - TextButton( - stringRes = R.string.username_or_password, - onClick = { - viewModel.cancelQuickConnect() - viewModel.clearSwitchUserState() - useQuickConnect = false - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } else { -// val username = rememberTextFieldState() -// val password = rememberTextFieldState() - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val onSubmit = { - viewModel.login( - server, - username, - password, - ) - } - val focusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } else if (state.quickConnectStatus != null) { Text( - text = "Enter username/password to login to ${server.name ?: server.url}", + text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - UserStateError(userState) - Row( - verticalAlignment = Alignment.CenterVertically, + Text( + text = state.quickConnectStatus?.code ?: "Failed to get code", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Username", - modifier = Modifier.padding(end = 8.dp), - ) - EditTextBox( - value = username, - onValueChange = { username = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - // onKeyboardAction = { + ) + } + UserStateError(state.switchUserState) + TextButton( + stringRes = R.string.username_or_password, + onClick = { + viewModel.cancelQuickConnect() + viewModel.clearSwitchUserState() + useQuickConnect = false + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } else { + var password by remember { mutableStateOf("") } + val onSubmit = { + viewModel.login( + server, + addUser, + username, + password, + ) + } + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (username.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } + Text( + text = "Enter username/password to login to ${server.name ?: server.url}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + UserStateError(state.switchUserState) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { username = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + // onKeyboardAction = { // passwordFocusRequester.tryRequestFocus() // }, - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Password", - modifier = Modifier.padding(end = 8.dp), - ) - LaunchedEffect(password) { - viewModel.clearSwitchUserState() - } - EditTextBox( - value = password, - onValueChange = { password = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke() }, - ), - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - TextButton( - stringRes = R.string.login, - onClick = { onSubmit.invoke() }, - enabled = username.isNotNullOrBlank(), - modifier = Modifier.align(Alignment.CenterHorizontally), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(focusRequester), ) } - if (loginAttempts > 2) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { Text( - text = "Trouble logging in?", - modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.password), + modifier = Modifier.padding(end = 8.dp), ) - TextButton( - stringRes = R.string.show_debug_info, - onClick = { - viewModel.navigationManager.navigateTo(Destination.Debug) - }, - modifier = Modifier.align(Alignment.CenterHorizontally), + LaunchedEffect(password) { + viewModel.clearSwitchUserState() + } + EditTextBox( + value = password, + onValueChange = { password = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke() }, + ), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(passwordFocusRequester), ) } + TextButton( + stringRes = R.string.login, + onClick = { onSubmit.invoke() }, + enabled = username.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + if (state.loginAttempts > 2) { + Text( + text = "Trouble logging in?", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + TextButton( + stringRes = R.string.show_debug_info, + onClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) } } } - switchUserWithPin?.let { user -> - PinEntryDialog( - onDismissRequest = { switchUserWithPin = null }, - onClickServerAuth = { - showAddUser = true - switchUserWithPin = null - }, - onTextChange = { - if (it == user.pin) viewModel.switchUser(user) - }, - ) - } + } + switchUserWithPin?.let { user -> + PinEntryDialog( + onDismissRequest = { switchUserWithPin = null }, + onClickServerAuth = { + showAddUserDialog(user) + switchUserWithPin = null + }, + onTextChange = { + if (it == user.pin) { + 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 68b9e583..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,8 +1,10 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.lifecycle.MutableLiveData +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 @@ -11,18 +13,23 @@ import com.github.damontecres.wholphin.services.ImageUrlService 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.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain -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 kotlinx.coroutines.Dispatchers +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay -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.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions @@ -32,6 +39,7 @@ 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 @@ -41,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, @@ -54,90 +63,110 @@ class SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel } - val serverQuickConnect = MutableLiveData<Boolean>(false) - - val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf()) - val quickConnectState = MutableLiveData<QuickConnectResult?>(null) + private val _state = MutableStateFlow(SwitchUserState()) + val state: StateFlow<SwitchUserState> = _state private var quickConnectJob: Job? = null - val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending) - - val loginAttempts = MutableLiveData(0) - fun clearSwitchUserState() { - switchUserState.value = LoadingState.Pending + _state.update { it.copy(switchUserState = LoadingState.Pending) } } fun resetAttempts() { - loginAttempts.value = 0 - } - - init { - init() + _state.update { it.copy(loginAttempts = 0) } } fun init() { - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + viewModelScope.launchDefault { serverRepository.switchServerOrUser() } quickConnectJob?.cancel() viewModelScope.launchIO { - users.setValueOnMain(listOf()) - val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.setValueOnMain(serverUsers) + _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) { + Timber.e(ex, "Error fetching users for server $server") + _state.update { + it.copy( + loading = LoadingState.Error(ex), + ) + } } } viewModelScope.launchIO { try { - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() val quickConnect by jellyfin - .createApi(server.url) - .quickConnectApi + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).quickConnectApi .getQuickConnectEnabled() - withContext(Dispatchers.Main) { - serverQuickConnect.value = quickConnect - } + _state.update { it.copy(quickConnectEnabled = quickConnect) } + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.w(ex, "Error checking quick connect for server ${server.url}") - withContext(Dispatchers.Main) { - serverQuickConnect.value = false - } + _state.update { it.copy(quickConnectEnabled = false) } } } } - fun switchUser(user: JellyfinUser) { - viewModelScope.launchIO { + fun trySwitchUser(user: JellyfinUser): Deferred<SwitchUserResult> = + viewModelScope.async(WholphinDispatchers.IO) { try { val current = serverRepository.changeUser(server, user) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + navigationManager.reloadHome() + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + SwitchUserResult.Success + } catch (ex: InvalidStatusException) { + 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") - setError("Error switching user", ex) + SwitchUserResult.Error(ex.localizedMessage, true) } } - } fun login( server: JellyfinServer, + existingUser: JellyfinUser?, username: String, password: String, ) { @@ -149,19 +178,13 @@ class SwitchUserViewModel username = username, password = password, ) - val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } - } + val current = + serverRepository.changeUser(server.url, authenticationResult, existingUser) + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (ex: Exception) { Timber.e(ex, "Error logging in user") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - switchUserState.value = - LoadingState.Error("Invalid username or password") - } + _state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) } } else { setError("Error during login", ex) } @@ -169,50 +192,51 @@ class SwitchUserViewModel } } - fun initiateQuickConnect(server: JellyfinServer) { + fun initiateQuickConnect( + server: JellyfinServer, + existingUser: JellyfinUser?, + ) { quickConnectJob?.cancel() quickConnectJob = viewModelScope.launchIO { try { val api = jellyfin.createApi(server.url) - var state = + var quickConnectStatus = api .quickConnectApi .initiateQuickConnect() .content + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } - withContext(Dispatchers.Main) { - quickConnectState.value = state - } - - while (!state.authenticated) { + while (!quickConnectStatus.authenticated) { delay(5_000L) - state = + quickConnectStatus = api.quickConnectApi .getQuickConnectState( - secret = state.secret, + secret = quickConnectStatus.secret, ).content - withContext(Dispatchers.Main) { - quickConnectState.value = state - } + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } } val authenticationResult by api.userApi.authenticateWithQuickConnect( - QuickConnectDto(secret = state.secret), + QuickConnectDto(secret = quickConnectStatus.secret), ) - val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } - } + val current = + serverRepository.changeUser( + server.url, + authenticationResult, + existingUser, + ) + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - quickConnectState.value = null - serverQuickConnect.value = false + _state.update { + it.copy( + quickConnectEnabled = false, + quickConnectStatus = null, + ) } } setError("Error with Quick Connect", ex) @@ -222,39 +246,115 @@ class SwitchUserViewModel fun cancelQuickConnect() { quickConnectJob?.cancel() - quickConnectState.value = null + _state.update { + it.copy( + quickConnectStatus = null, + ) + } } fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.value = serverUsers - } + _state.update { it.copy(users = serverUsers) } } } - private suspend fun getUsers(): List<JellyfinUserAndImage> { + private suspend fun getUsers(): List<JellyfinUserAndImage> = + withContext(WholphinDispatchers.IO) { + val api = jellyfin.createApi(server.url) + val knownUsers = + serverDao + .getServer(server.id) + ?.users + .orEmpty() + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = true, + ) + } + val knownUserIds = knownUsers.map { it.user.id } + val publicUsers = + api.userApi + .getPublicUsers() + .content + .map { + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = null, + ) + }.filter { it.id !in knownUserIds } + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = false, + ) + } + + knownUsers + publicUsers + } + + private suspend fun checkServerVersion(): Pair<ServerVersion?, ServerVersionSupported> { val api = jellyfin.createApi(server.url) - return serverDao - .getServer(server.id) - ?.users - ?.sortedBy { it.name } - ?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) } - .orEmpty() + 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 suspend fun setError( + private fun setError( msg: String? = null, ex: Exception? = null, - ) = withContext(Dispatchers.Main) { - loginAttempts.value = (loginAttempts.value ?: 0) + 1 - switchUserState.value = LoadingState.Error(msg, ex) + ) { + _state.update { + it.copy( + loginAttempts = it.loginAttempts + 1, + switchUserState = LoadingState.Error(msg, ex), + ) + } } } data class JellyfinUserAndImage( val user: JellyfinUser, val imageUrl: String?, + val known: Boolean, ) + +data class SwitchUserState( + // LoadingState for fetching available users + val loading: LoadingState = LoadingState.Pending, + val quickConnectEnabled: Boolean = false, + val quickConnectStatus: QuickConnectResult? = null, + val users: List<JellyfinUserAndImage> = emptyList(), + // 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 1b35cce6..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 @@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID @@ -75,7 +77,7 @@ fun UserList( onSwitchServer: () -> Unit, modifier: Modifier = Modifier, ) { - var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) } + var showDeleteDialog by remember { mutableStateOf<JellyfinUserAndImage?>(null) } Column( modifier = modifier, @@ -87,27 +89,32 @@ 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( user = user, isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, - onLongClick = { showDeleteDialog = user.user }, - modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, + onLongClick = { showDeleteDialog = user }, + modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier, ) } // Add User card - always rightmost @@ -152,28 +159,33 @@ fun UserList( showDeleteDialog?.let { user -> DialogPopup( showDialog = true, - title = user.name ?: user.id.toString(), + title = user.user.name ?: user.user.id.toServerString(), dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_user), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchUser.invoke(user) - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveUser.invoke(user) - }, - ), + buildList { + add( + DialogItem( + stringResource(R.string.switch_user), + R.string.fa_arrow_left_arrow_right, + ) { + onSwitchUser.invoke(user.user) + }, + ) + if (user.known) { + add( + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + onRemoveUser.invoke(user.user) + }, + ) + } + }, onDismissRequest = { showDeleteDialog = null }, dismissOnClick = true, waitToLoad = true, properties = DialogProperties(), - elevation = 5.dp, ) } } 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 d2b48c35..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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup 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.size import androidx.compose.foundation.layout.width @@ -31,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 @@ -42,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, @@ -55,26 +56,36 @@ 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 = "Enter URL & API Key", + text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) + val labelWidth = 90.dp Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.url), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = url, @@ -104,8 +115,11 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.api_key), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = apiKey, @@ -146,6 +160,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -160,20 +175,28 @@ 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, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) val labelWidth = 90.dp Row( @@ -311,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 3601fdd7..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,23 +1,164 @@ 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, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } @@ -32,13 +173,16 @@ fun AddSeerServerDialog( -> { BasicDialog( onDismissRequest = { authMethod = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, username = currentUsername ?: "", status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -48,10 +192,12 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -59,7 +205,10 @@ fun AddSeerServerDialog( null -> { ChooseSeerrLoginType( onDismissRequest = onDismissRequest, - onChoose = { authMethod = it }, + onChoose = { + onResetStatus.invoke() + authMethod = it + }, ) } } @@ -71,27 +220,26 @@ fun ChooseSeerrLoginType( onChoose: (SeerrAuthMethod) -> Unit, ) { val params = - remember { - DialogParams( - fromLongClick = false, - title = "Login to Seerr server", - items = - listOf( - DialogItem( - text = "API Key", - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - DialogItem( - text = "Jellyfin user", - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = "Local user", - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, ), - ) - } + DialogItem( + 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) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, 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 4f93217a..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,112 +62,164 @@ 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 } - var result: LoadingState = LoadingState.Error("No url") + Timber.v("Urls to try: %s", urls) + val results = mutableMapOf<HttpUrl, LoadingState>() for (url in urls) { Timber.d("Trying %s", url) - result = - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in") - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - result = LoadingState.Error("Invalid credentials", ex) - break - } else { - LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception logging in") - LoadingState.Error(ex) - } - if (result is LoadingState.Success) { - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + results[url] = LoadingState.Success break + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in %s", url) + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + results[url] = LoadingState.Error("Invalid credentials", ex) + } else { + results[url] = LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "ClientException logging in %s", url) + results[url] = LoadingState.Error(ex) } } - if (result is LoadingState.Error) { - showToast(context, "Error: ${result.message}") + val result = results.filter { (url, state) -> state is LoadingState.Success } + if (result.isNotEmpty()) { + val url = result.keys.first() + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } + } else { + val message = + results + .map { (url, state) -> + val s = state as? LoadingState.Error + "$url - ${s?.localizedMessage}" + }.joinToString("\n") + showToast(context, "Could not connect") + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } - serverConnectionStatus.update { result } } } fun removeServer() { viewModelScope.launchIO { - seerrServerRepository.removeServer() + val result = seerrServerRepository.removeServerForCurrentUser() + if (!result) { + showToast(context, "Could not remove server") + } + } + } + + 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<String>() + val urls = mutableListOf<HttpUrl>() if (url.startsWith("http://") || url.startsWith("https://")) { - urls.add(url) val httpUrl = url.toHttpUrl() + urls.add(httpUrl) if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { - urls.add("$url:5055") + urls.add(httpUrl.newBuilder().port(5055).build()) } } else { - urls.add("http://$url") val httpUrl = "http://$url".toHttpUrl() + urls.add(httpUrl) if (httpUrl.port == 80) { - urls.add("https://$url") - urls.add("http://$url:5055") - urls.add("https://$url:5055") + urls.add(httpUrl.newBuilder().scheme("https").build()) + urls.add(httpUrl.newBuilder().port(5055).build()) + urls.add( + httpUrl + .newBuilder() + .scheme("https") + .port(5055) + .build(), + ) } else { - urls.add("https://$url") + urls.add(httpUrl.newBuilder().scheme("https").build()) } } - return urls.map { cleanUrl(it).toHttpUrl() } + return urls } -private fun cleanUrl(url: String) = - if (!url.endsWith("/api/v1")) { +fun createSeerrApiUrl(url: String): String = + if (url.isBlank()) { + url + } else if (url.endsWith("/api/v1") || url.endsWith("/api/v1/")) { + url + } else { url .toHttpUrl() .newBuilder() - .apply { - addPathSegment("api") - addPathSegment("v1") - }.build() + .addPathSegment("api") + .addPathSegment("v1") + .build() .toString() - } else { - url } + +fun migrateSeerrUrl(url: String): String { + var url = url.removeSuffix("/api/v1/").removeSuffix("/api/v1") + if (!url.endsWith("/")) url += "/" + return url +} 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 a513715d..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 @@ -8,17 +8,14 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect 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 @@ -54,31 +51,34 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi import coil3.compose.SubcomposeAsyncImage +import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest -import coil3.request.crossfade +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 import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "ImagePage" private const val DEBUG = false @SuppressLint("ConfigurationScreenWidthHeight") @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalCoilApi::class) @Composable fun SlideshowPage( slideshow: Destination.Slideshow, @@ -91,419 +91,421 @@ fun SlideshowPage( ), ) { val context = LocalContext.current + val state by viewModel.state.collectAsState() - 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 imageState by viewModel.image.observeAsState() - - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } - - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } - - override fun stopSlideshow() { - viewModel.stopSlideshow() - } + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) } - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", - ) - - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) - - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width - - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f - - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } - - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) - } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff - } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff - } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } + LoadingState.Success -> { + val loadingState = state.imageLoading + val imageFilter by viewModel.imageFilter.collectAsState() + val position = state.position + val pager = state.items +// val imageState by viewModel.image.observeAsState() - LaunchedEffect(imageState) { - reset(true) - } - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - context.findActivity()?.keepScreenOn(slideshowActive) - } - DisposableEffect(Unit) { - onDispose { - context.findActivity()?.keepScreenOn(false) - } - } + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } - var longPressing by remember { mutableStateOf(false) } + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, + override fun stopSlideshow() { + viewModel.stopSlideshow() + } + } + + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", ) - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) + val slideshowState by viewModel.state.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } - } - return@onKeyEvent true - } - } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) - } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() - } - } + val focusRequester = remember { FocusRequester() } - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { - Toast - .makeText( - context, - R.string.no_more_images, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } else if (isOverlayShowing && it.key == Key.Back) { - showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true - } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() - } - result - }, - ) { - when (loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null) + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() } - ImageLoadingState.Loading -> { - LoadingPage() + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f } - is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } + + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } + + var longPressing by remember { mutableStateOf(false) } + + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) + + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true } } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() } + } - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, - ) + } + result + }, + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) } + + ImageLoadingState.Loading -> { + LoadingPage(modifier, false) + } + + is ImageLoadingState.Success -> { + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) + } + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = + TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .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, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) + } + } + } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> {} + + ImageLoadingState.Loading -> {} + + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( + modifier = + Modifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } + } + } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() + }, + ) } } } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) - } - } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } } } 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 4d2d0662..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 @@ -16,26 +16,29 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference +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 @@ -67,6 +70,7 @@ class SlideshowViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val userPreferencesService: UserPreferencesService, + private val screensaverService: ScreensaverService, @Assisted val slideshowSettings: Destination.Slideshow, ) : ViewModel(), Player.Listener { @@ -75,101 +79,108 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - val player by lazy { - playerFactory.createVideoPlayer() - } + lateinit var player: Player + private set private var saveFilters = true /** * 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 album = MutableLiveData<BaseItem>() - 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 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() init { addCloseable { - player.removeListener(this@SlideshowViewModel) - player.release() - } - player.addListener(this@SlideshowViewModel) - viewModelScope.launchIO { - val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue -// val album = -// api.userLibraryApi -// .getItem( -// itemId = slideshowSettings.parentId, -// ).content -// .let { BaseItem(it, false) } -// this@SlideshowViewModel.album.setValueOnMain(album) - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = true, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser.value?.let { user -> - val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter - } + screensaverService.keepScreenOn(false) + if (this@SlideshowViewModel::player.isInitialized) { + player.removeListener(this@SlideshowViewModel) + player.release() + } + } + viewModelScope.launchIO { + try { + val appPreferences = userPreferencesService.getCurrent().appPreferences + val playerCreation = + playerFactory.createVideoPlayer( + backend = PlayerBackend.EXO_PLAYER, + appPreferences, + ) + player = playerCreation.player + player.addListener(this@SlideshowViewModel) + + val photoPrefs = appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = slideshowSettings.recursive, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser?.let { user -> + + val filter = + 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 + } + } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + _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") + _state.update { it.copy(loading = LoadingState.Error(ex)) } } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } } 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 @@ -177,23 +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 = pager.getBlocking(position) + 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) { @@ -213,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, ) } @@ -237,7 +267,7 @@ class SlideshowViewModel updateImageFilter(albumImageFilter) if (saveFilters) { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val vf = playbackEffectDao .getPlaybackEffect( @@ -249,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") } } } @@ -282,10 +343,11 @@ class SlideshowViewModel private var slideshowJob: Job? = null fun startSlideshow() { - _slideshow.update { - SlideshowState(enabled = true, paused = false) + screensaverService.keepScreenOn(true) + _state.update { + it.copy(enabled = true, paused = false) } - if (_image.value + if (state.value.image ?.image ?.data ?.mediaType != MediaType.VIDEO @@ -295,15 +357,16 @@ 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) @@ -315,7 +378,7 @@ class SlideshowViewModel fun unpauseSlideshow() { Timber.v("unpauseSlideshow") - _slideshow.update { + _state.update { if (it.enabled) { it.copy(paused = false) } else { @@ -327,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 @@ -345,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( @@ -365,7 +428,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for image %s", it.image.id) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -381,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( @@ -394,7 +457,7 @@ class SlideshowViewModel ), ) Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) - withContext(Dispatchers.Main) { + withContext(WholphinDispatchers.Main) { showToast( context, "Saved", @@ -440,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 9cae3698..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 @@ -35,33 +37,44 @@ import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem -import com.github.damontecres.wholphin.ui.playback.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton import com.github.damontecres.wholphin.ui.preferences.SliderPreference import com.github.damontecres.wholphin.ui.preferences.SwitchPreference 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 5358a14e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ /dev/null @@ -1,113 +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.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.yearsApi -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) } - } - - 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 d07f86ff..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.TimeFormatter -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 [TimeFormatter] + * The current time formatted as a string; populated by [ProvideLocalClock] via [formatTime]. */ - val timeString: MutableState<String> = mutableStateOf(TimeFormatter.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.IO) { + LaunchedEffect(context) { + withContext(WholphinDispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = TimeFormatter.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/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt new file mode 100644 index 00000000..73f22de6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.lazy.LazyColumn + +/** + * Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels + * + * Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too + * + * Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides + * + * Example: + * ```kotlin + * val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + * CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) { + * LazyColumn{ + * items(list){ + * CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + * // Content + * } + * } + * } + * } + * ``` + */ +class ScrollToTopBringIntoViewSpec( + val spaceAbovePx: Float = 100f, +) : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float { +// Timber.v( +// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s", +// offset, +// size, +// containerSize, +// ) + return offset - spaceAbovePx + } +} 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 ff4e62da..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 @@ -27,24 +27,24 @@ object StreamFormatting { resolutionString(height, width, interlaced) } else { when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" + width > 5120 || height > 4320 -> "8K" + width > 2560 || height > 1440 -> "4K" + width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) + width > 1280 || height > 962 -> "1080" + interlaced(interlaced) + width > 1024 || height > 576 -> "720" + interlaced(interlaced) + width > 960 || height > 544 -> "576" + interlaced(interlaced) + width > 845 || height > 480 -> "540" + interlaced(interlaced) + width > 720 || height > 404 -> "480" + interlaced(interlaced) + width > 682 || height > 384 -> "404" + interlaced(interlaced) + width > 640 || height > 360 -> "384" + interlaced(interlaced) + width > 426 || height > 240 -> "360" + interlaced(interlaced) + width > 256 || height > 144 -> "240" + interlaced(interlaced) else -> height.toString() + interlaced(interlaced) } } 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,39 +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:X", true) == true -> { - context.getString(R.string.dts_x) - } - - profile?.contains("DTS:HD", true) == true -> { - context.getString(R.string.dts_hd) + profile?.contains("DTS", true) == true -> { + when { + 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, @@ -140,7 +141,7 @@ object StreamFormatting { } fun mediaStreamDisplayTitle( - context: Context, + resources: Resources, stream: MediaStream, includeFlags: Boolean, ): String { @@ -148,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 b03ce9f9..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 @@ -1,205 +1,120 @@ package com.github.damontecres.wholphin.util -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.data.model.BaseItem 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 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 import org.jellyfin.sdk.api.client.extensions.personsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi 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 import timber.log.Timber import java.util.UUID -import java.util.function.Predicate /** - * Handles paging for an API request. You must call [init] prior to use. - * - * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. - * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. - * - * Finally, items are cached allow for backward and forward scrolling. + * A [RequestPager] for Jellyfin server queries */ class ApiRequestPager<T>( val api: ApiClient, val request: T, val requestHandler: RequestHandler<T>, - private val scope: CoroutineScope, - private val pageSize: Int = DEFAULT_PAGE_SIZE, + scope: CoroutineScope, + pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, private val useSeriesForPrimary: Boolean = false, -) : AbstractList<BaseItem?>(), - BlockingList<BaseItem?> { - private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf())) - private var totalCount by mutableIntStateOf(-1) - private val mutex = Mutex() - private val cachedPages = - CacheBuilder - .newBuilder() - .maximumSize(cacheSize) - .build<Int, MutableList<BaseItem>>() +) : RequestPager<BaseItem>(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T> - suspend fun init(initialPosition: Int = 0): ApiRequestPager<T> { - if (totalCount < 0) { - fetchPageBlocking(initialPosition, true) - } - return this - } - - override operator fun get(index: Int): BaseItem? { - if (index in 0..<totalCount) { - val item = items[index] - if (item == null) { - fetchPage(index) - } - return item - } else { - throw IndexOutOfBoundsException("$index of $totalCount") - } - } - - override suspend fun getBlocking(index: Int): BaseItem? { - if (index in 0..<totalCount) { - val item = items[index] - if (item == null) { - fetchPageBlocking(index, false) - return items[index] - } - return item - } else { - throw IndexOutOfBoundsException("$index of $totalCount") - } - } - - override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int { - init() - for (i in 0 until totalCount) { - val currentItem = getBlocking(i) - if (currentItem != null && predicate.test(currentItem)) { - return i - } - } - return -1 - } - - override val size: Int - get() = totalCount - - private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { - fetchPageBlocking(position, false) - } - - private suspend fun fetchPageBlocking( - position: Int, - setTotalCount: Boolean, - ) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - setTotalCount, - ) - val result = requestHandler.execute(api, newRequest).content - if (setTotalCount) { - totalCount = result.totalRecordCount.coerceAtLeast(0) - } - val data = mutableListOf<BaseItem>() - result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } - cachedPages.put(pageNumber, data) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) - } - } + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult<BaseItem> { + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + includeTotalCount, + ) + val result = requestHandler.execute(api, newRequest).content + val data = mutableListOf<BaseItem>() + result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) } + return QueryResult(data, result.totalRecordCount) } suspend fun refreshItem( position: Int, itemId: UUID, ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from( - it, - api, - useSeriesForPrimary, - ) - } - val pageNumber = position / pageSize - val index = position - pageNumber * pageSize mutex.withLock { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize val page = cachedPages.getIfPresent(pageNumber) if (page != null && index in page.indices) { page[index] = item cachedPages.put(pageNumber, page) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) + items = ItemList(size, pageSize, cachedPages.asMap()) } } } - companion object { - private const val DEBUG = false - } - - class ItemList<T>( - val size: Int, - val pageSize: Int, - val pages: Map<Int, List<T>>, - ) { - operator fun get(position: Int): T? { - val page = position / pageSize - val data = pages[page] - if (data != null) { - val index = position % pageSize - if (index in data.indices) { - return data[index] - } else { - // This can happen when items are removed while scrolling - Timber.w( - "Index $index not in data: position=$position, data.size=${data.size}", - ) - return null + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) } - } else { - return null } } + fetchPageBlocking(position, true) } } /** - * Specifies how the [ApiRequestPager] should prepare and execute API calls + * 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) */ @@ -217,180 +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 + } } -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, - ) +@Serializable +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) +} -val GetEpisodesRequestHandler = - object : RequestHandler<GetEpisodesRequest> { - override fun prepare( - request: GetEpisodesRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetEpisodesRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +@Serializable +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) +} -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, - ) +@Serializable +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) +} -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, - ) +@Serializable +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) +} -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, - ) +@Serializable +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) +} -val GetPlaylistItemsRequestHandler = - object : RequestHandler<GetPlaylistItemsRequest> { - override fun prepare( - request: GetPlaylistItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPlaylistItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +@Serializable +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) +} -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, - ) +@Serializable +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) - } + 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, - ) +@Serializable +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) - } + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) +} -val GetPersonsHandler = - object : RequestHandler<GetPersonsRequest> { - override fun prepare( - request: GetPersonsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPersonsRequest = - request.copy( +@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)) + 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 75ccc38e..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 @@ -1,9 +1,44 @@ 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 + */ interface BlockingList<T> : List<T> { + /** + * Get the specified index, possibly blocking until it is available + */ suspend fun getBlocking(index: Int): T + /** + * Get first index that matches the given predicate, possibly blocking while searching + */ 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] + */ + fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list) + } +} + +private class BlockingListWrapper<T>( + private val list: List<T>, +) : BlockingList<T>, + List<T> by list { + override suspend fun getBlocking(index: Int): T = get(index) + + override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) } + + @Deprecated("Deprecated") + override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator) } 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 c0e93556..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 @@ -9,6 +9,7 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -31,6 +32,8 @@ val supportedCollectionTypes = CollectionType.LIVETV, CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, + CollectionType.MUSIC, + CollectionType.PHOTOS, null, // Mixed ) @@ -39,7 +42,20 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, 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 new file mode 100644 index 00000000..412130d7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.ApiClientFactory +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.HttpMethod +import org.jellyfin.sdk.api.client.RawResponse +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.api.sockets.SocketApi +import org.jellyfin.sdk.api.sockets.SocketConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import kotlin.coroutines.CoroutineContext + +/** + * Wraps [ApiClient.request] with the given [CoroutineContext] + */ +class CoroutineContextApiClient( + private val client: ApiClient, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, +) : ApiClient() { + override val baseUrl: String? + get() = client.baseUrl + override val accessToken: String? + get() = client.accessToken + override val clientInfo: ClientInfo + get() = client.clientInfo + override val deviceInfo: DeviceInfo + get() = client.deviceInfo + override val httpClientOptions: HttpClientOptions + get() = client.httpClientOptions + override val webSocket: SocketApi + get() = client.webSocket + + override fun update( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) { + client.update(baseUrl, accessToken, clientInfo, deviceInfo) + } + + override suspend fun request( + method: HttpMethod, + pathTemplate: String, + pathParameters: Map<String, Any?>, + queryParameters: Map<String, Any?>, + requestBody: Any?, + ): RawResponse = + withContext(coroutineContext) { + client.request( + method, + pathTemplate, + pathParameters, + queryParameters, + requestBody, + ) + } +} + +class CoroutineContextApiClientFactory( + private val factory: OkHttpFactory, + private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, +) : ApiClientFactory, + SocketConnectionFactory { + override fun create( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, + ): ApiClient = + CoroutineContextApiClient( + factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), + coroutineContext, + ) + + override fun create( + clientOptions: HttpClientOptions, + scope: CoroutineScope, + ): SocketConnection = factory.create(clientOptions, scope) +} 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 43d3e45a..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,8 +1,9 @@ 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 import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -12,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 @@ -19,6 +21,9 @@ import org.json.JSONObject import timber.log.Timber import java.util.Date +/** + * Sends a crash report to the current server + */ @AutoService(ReportSenderFactory::class) class CrashReportSenderFactory : ReportSenderFactory { override fun create( @@ -35,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 = AppModule.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) @@ -71,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/DebugLogTree.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt index 46b54466..b7f2e2c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt @@ -4,6 +4,9 @@ import android.util.Log import com.github.damontecres.wholphin.BuildConfig import timber.log.Timber +/** + * Enable debug logging via [Timber] if enabled in the app settings + */ class DebugLogTree private constructor() : Timber.Tree() { // Only add logging for below INFO, production logger in WholphinApplication logs >=INFO override fun isLoggable( 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 new file mode 100644 index 00000000..ceb4f160 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt @@ -0,0 +1,140 @@ +package com.github.damontecres.wholphin.util + +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 + */ +class DiscoverRequestPager<T>( + private val api: SeerrApi, + val requestHandler: DiscoverRequestHandler<T>, + val transform: suspend (T) -> DiscoverItem, + scope: CoroutineScope, + pageSize: Int = SEERR_PAGE_SIZE, + cacheSize: Long = 16, +) : RequestPager<DiscoverItem>(scope, pageSize, cacheSize) { + override suspend fun init(initialPosition: Int): DiscoverRequestPager<T> = super.init(initialPosition) as DiscoverRequestPager<T> + + override suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult<DiscoverItem> { + val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed + val transformed = result.items.map { transform.invoke(it) } + return QueryResult(transformed, result.totalCount) + } +} + +const val SEERR_PAGE_SIZE = 20 + +enum class DiscoverRequestType( + @param:StringRes val stringRes: Int, +) { + DISCOVER_TV(R.string.discover_tv), + DISCOVER_MOVIES(R.string.discover_movies), + TRENDING(R.string.trending), + UPCOMING_TV(R.string.upcoming_tv), + UPCOMING_MOVIES(R.string.upcoming_movies), + 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 + */ +interface DiscoverRequestHandler<T> { + suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<T> +} + +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) + } +} + +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> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<SeerrSearchResult> = + api.api.searchApi.discoverTrendingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingTvRequestHandler = + object : DiscoverRequestHandler<TvResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<TvResult> = + api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } + +val UpcomingMovieRequestHandler = + object : DiscoverRequestHandler<MovieResult> { + override suspend fun execute( + api: SeerrApi, + pageNumber: Int, + ): QueryResult<MovieResult> = + api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let { + QueryResult(it.results.orEmpty(), it.totalResults ?: 0) + } + } 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/FocusPair.kt b/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt deleted file mode 100644 index e44d531a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.compose.ui.focus.FocusRequester - -data class FocusPair( - val row: Int, - val column: Int, - val focusRequester: FocusRequester, -) 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 c35e9512..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 @@ -1,6 +1,9 @@ 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 @@ -46,23 +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 { @@ -95,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/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt index 89b900f0..b88bcb8a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.time.LocalDate +import java.time.LocalDateTime import java.time.format.DateTimeFormatter object LocalDateSerializer : KSerializer<LocalDate> { @@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer<LocalDate> { override fun deserialize(decoder: Decoder): LocalDate = decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } } + +object LocalDateTimeSerializer : KSerializer<LocalDateTime> { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDateTime, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDateTime = + decoder + .decodeString() + .let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } +} 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/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt deleted file mode 100644 index 1c69d947..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.damontecres.wholphin.util - -import com.github.damontecres.wholphin.preferences.UserPreferences -import java.util.UUID - -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 new file mode 100644 index 00000000..10f0f64f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt @@ -0,0 +1,154 @@ +package com.github.damontecres.wholphin.util + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +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.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.function.Predicate + +/** + * Handles paging for an API request. You must call [init] prior to use. + * + * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. + * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. + * + * Finally, items are cached allow for backward and forward scrolling. + */ +abstract class RequestPager<T>( + protected val scope: CoroutineScope, + val pageSize: Int = DEFAULT_PAGE_SIZE, + cacheSize: Long = 8, +) : AbstractList<T?>(), + BlockingList<T?> { + protected var totalCount by mutableIntStateOf(-1) + protected var items by mutableStateOf(ItemList<T>(0, pageSize, mapOf())) + protected val mutex = Mutex() + protected val cachedPages = + CacheBuilder + .newBuilder() + .maximumSize(cacheSize) + .build<Int, MutableList<T>>() + + open suspend fun init(initialPosition: Int = 0): RequestPager<T> { + if (totalCount < 0) { + fetchPageBlocking(initialPosition, true) + } + return this + } + + override operator fun get(index: Int): T? { + if (index in 0..<totalCount) { + val item = items[index] + if (item == null) { + fetchPage(index) + } + return item + } else { + throw IndexOutOfBoundsException("$index of $totalCount") + } + } + + override suspend fun getBlocking(index: Int): T? { + if (index in 0..<totalCount) { + val item = items[index] + if (item == null) { + fetchPageBlocking(index, false) + return items[index] + } + return item + } else { + throw IndexOutOfBoundsException("$index of $totalCount") + } + } + + override suspend fun indexOfBlocking(predicate: Predicate<T?>): Int { + init(0) + for (i in 0 until totalCount) { + val currentItem = getBlocking(i) + if (currentItem != null && predicate.test(currentItem)) { + return i + } + } + return -1 + } + + override val size: Int + get() = totalCount + + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + fetchPageBlocking(position, false) + } + + protected suspend fun fetchPageBlocking( + position: Int, + setTotalCount: Boolean, + ) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val result = fetchPage(pageNumber, setTotalCount) + if (setTotalCount && result.totalCount != null) { + totalCount = result.totalCount.coerceAtLeast(0) + } + cachedPages.put(pageNumber, result.items.toMutableList()) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + + suspend fun refresh() { + cachedPages.asMap().keys.forEachIndexed { index, pageNum -> + if (DEBUG) Timber.v("refresh: pageNum=%s", pageNum) + fetchPageBlocking(pageNum * pageSize, index == 0) + } + } + + protected abstract suspend fun fetchPage( + pageNumber: Int, + includeTotalCount: Boolean, + ): QueryResult<T> + + companion object { + internal const val DEBUG = false + } + + class ItemList<T>( + val size: Int, + val pageSize: Int, + val pages: Map<Int, List<T>>, + ) { + operator fun get(position: Int): T? { + val page = position / pageSize + val data = pages[page] + if (data != null) { + val index = position % pageSize + if (index in data.indices) { + return data[index] + } else { + // This can happen when items are removed while scrolling + Timber.w( + "Index $index not in data: position=$position, data.size=${data.size}", + ) + return null + } + } else { + return null + } + } + } +} + +data class QueryResult<T>( + val items: List<T>, + val totalCount: Int?, +) 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 b0c1f593..2ab4c2f7 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,14 +3,13 @@ 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 org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo @@ -20,6 +19,8 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks 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 @@ -30,10 +31,9 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - val playback: CurrentPlayback, - val itemPlayback: ItemPlayback, + private val getState: () -> PlaybackItemState?, ) : Player.Listener { - private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) private val task: TimerTask = object : TimerTask() { override fun run() { @@ -50,44 +50,53 @@ class TrackActivityPlaybackListener( fun init() { launch("reportPlaybackStart") { - Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") - api.playStateApi.reportPlaybackStart( - PlaybackStartInfo( - canSeek = true, - itemId = itemPlayback.itemId, - isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, - playMethod = playback.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - isMuted = false, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) - val delay = 5.seconds.inWholeMilliseconds - // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + getState.invoke()?.let { state -> + Timber.v("reportPlaybackStart for ${state.itemId}") + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = true, + itemId = state.itemId, + isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying }, + playMethod = state.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + audioStreamIndex = state.audioStreamIndex, + subtitleStreamIndex = state.subtitleStreamIndex, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + + val delay = 5.seconds.inWholeMilliseconds + // Every x seconds, check if the video is playing + TIMER.schedule(task, delay, delay) + initialized = true + } } } fun release() { +// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds launch("reportPlaybackStopped") { - Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") - api.playStateApi.reportPlaybackStopped( - PlaybackStopInfo( - itemId = itemPlayback.itemId, - positionTicks = position.inWholeTicks, - failed = false, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) + getState.invoke()?.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.takeIf { it >= 0 }, + failed = false, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) + } } } @@ -108,29 +117,31 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - val calcPosition = - withContext(Dispatchers.Main) { - (if (position >= 0) position else player.currentPosition) + getState.invoke()?.let { state -> + val calcPosition = + withContext(WholphinDispatchers.Main) { + (if (position >= 0) position else player.currentPosition) + } + if (calcPosition > 0) { + val isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying } + Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") + api.playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = state.itemId, + positionTicks = calcPosition.milliseconds.inWholeTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + playMethod = state.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + audioStreamIndex = state.audioStreamIndex, + subtitleStreamIndex = state.subtitleStreamIndex, + playSessionId = state.playSessionId, + liveStreamId = state.liveStreamId, + ), + ) } - if (calcPosition > 0) { - val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } - Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition") - api.playStateApi.reportPlaybackProgress( - PlaybackProgressInfo( - itemId = itemPlayback.itemId, - positionTicks = calcPosition.milliseconds.inWholeTicks, - canSeek = true, - isPaused = isPaused, - isMuted = false, - playMethod = playback.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, - subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, - playSessionId = playback.playSessionId, - liveStreamId = playback.liveStreamId, - ), - ) } } } @@ -143,7 +154,7 @@ class TrackActivityPlaybackListener( try { block.invoke(this) } catch (ex: Exception) { - Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) + Timber.w(ex, "Exception during %s", name) } } } @@ -154,3 +165,23 @@ class TrackActivityPlaybackListener( private val TIMER by lazy { Timer("$TAG-timer", true) } } } + +data class PlaybackItemState( + val itemId: UUID, + val playMethod: PlayMethod, + val audioStreamIndex: Int? = null, + val subtitleStreamIndex: Int? = null, + val playSessionId: String? = null, + val liveStreamId: String? = null, +) { + constructor( + playback: CurrentPlayback, + ) : this( + itemId = playback.item.id, + playMethod = playback.playMethod, + 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/TransformList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt index 61946d60..a0bfc08b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.util +/** + * A utility class to lazily transforms items from the source list + */ class TransformList<S, T>( private val source: List<S>, private val transform: (S) -> T, 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 03303ac8..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 @@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.util import kotlinx.serialization.Serializable +/** + * Represents a version in the format of `<major>.<minor>.<patch>-<numCommits>-g<gitSha>` as output by `git describe` + */ @Serializable data class Version( val major: Int, @@ -67,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 b98ececd..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 @@ -2,14 +2,16 @@ 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 import org.jellyfin.sdk.model.api.DlnaProfileType 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 @@ -59,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, @@ -66,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 { @@ -92,16 +99,21 @@ fun createDeviceProfile( val hevcMainLevel = mediaTest.getHevcMainLevel() val hevcMain10Level = mediaTest.getHevcMain10Level() val supportsAVC = mediaTest.supportsAVC() - val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() + val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52 val avcMainLevel = mediaTest.getAVCMainLevel() - val avcHigh10Level = mediaTest.getAVCHigh10Level() + val avcHigh10Level = + if (supportsHi10P52) { + MediaCodecInfo.CodecProfileLevel.AVCLevel52 + } else { + mediaTest.getAVCHigh10Level() + } 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 @@ -135,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 @@ -146,9 +168,14 @@ fun createDeviceProfile( type = DlnaProfileType.AUDIO context = EncodingContext.STREAMING - container = Codec.Container.MP3 + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.MP3) + if (preferAc3ForSurround) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -195,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 @@ -407,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 @@ -509,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 0a936de9..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 @@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile import android.os.Build /** - * List of devie models with known HEVC DoVi/HDR10+ playback issues. + * List of device models with known HEVC DoVi/HDR10+ playback issues. */ private val modelsWithDoViHdr10PlusBug = listOf( @@ -14,6 +14,30 @@ 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 + * + * Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html + */ +private val modelsWithHi10P52Support = + listOf( + "AFTMA08C15", // Fire TV Stick 4K Plus (2025) + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + "AFTKA", // Amazon Fire TV 4K Max (1st Gen) + "AFTKM", // Amazon Fire TV 4K (2nd Gen) + "AFTMM", // Fire TV Stick 4K - 1st Gen (2018) + ) + 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 23a3cc0c..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 @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.util.profile -// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt +// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.7/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt import android.content.Context import android.media.MediaCodecInfo.CodecProfileLevel @@ -41,11 +41,46 @@ class MediaCodecCapabilitiesTest( -1 } } - val Profile10: Int by lazy { + } + + // Some devices (e.g., Fire OS) may support AV1 below the official API level + // Use the platform constant if the API level is met; otherwise fall back to the literal value + // Reference: + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/media/java/android/media/MediaCodecInfo.java + private object AV1ProfileLevel { + val ProfileMain10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10 + } else { + 0x2 + } + } + val ProfileMain10HDR10: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10 + } else { + 0x1000 + } + } + val ProfileMain10HDR10Plus: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1ProfileMain10HDR10Plus + } else { + 0x2000 + } + } + val DolbyVisionProfile10: Int by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { CodecProfileLevel.DolbyVisionProfileDvav110 } else { - -1 + 0x400 + } + } + val Level5: Int by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + CodecProfileLevel.AV1Level5 + } else { + 0x1000 } } } @@ -90,41 +125,36 @@ class MediaCodecCapabilitiesTest( CodecProfileLevel.HEVCMainTierLevel62 to 186, ) - fun supportsAV1(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1) + fun supportsAV1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1) fun supportsAV1Main10(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10, + AV1ProfileLevel.Level5, + ) fun supportsAV1DolbyVision(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( - MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, - DolbyVisionProfiles.Profile10, + MimeTypes.VIDEO_DOLBY_VISION, + AV1ProfileLevel.DolbyVisionProfile10, CodecProfileLevel.DolbyVisionLevelHd24, ) fun supportsAV1HDR10(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10HDR10, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10HDR10, + AV1ProfileLevel.Level5, + ) fun supportsAV1HDR10Plus(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AV1, - CodecProfileLevel.AV1ProfileMain10HDR10Plus, - CodecProfileLevel.AV1Level5, - ) + hasDecoder( + MimeTypes.VIDEO_AV1, + AV1ProfileLevel.ProfileMain10HDR10Plus, + AV1ProfileLevel.Level5, + ) fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC) @@ -179,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/jni/Android.mk b/app/src/main/jni/Android.mk deleted file mode 100644 index 798ae9a2..00000000 --- a/app/src/main/jni/Android.mk +++ /dev/null @@ -1,82 +0,0 @@ -LOCAL_PATH:= $(call my-dir) - -ifeq ($(TARGET_ARCH_ABI),armeabi-v7a) -PREFIX = $(PREFIX32) -endif -ifeq ($(TARGET_ARCH_ABI),arm64-v8a) -PREFIX = $(PREFIX64) -endif -ifeq ($(TARGET_ARCH_ABI),x86_64) -PREFIX = $(PREFIX_X64) -endif -ifeq ($(TARGET_ARCH_ABI),x86) -PREFIX = $(PREFIX_X86) -endif - -include $(CLEAR_VARS) -LOCAL_MODULE := libswresample -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libpostproc -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -# only include if library file exists -ifneq (,$(wildcard $(LOCAL_SRC_FILES))) -include $(PREBUILT_SHARED_LIBRARY) -endif - -include $(CLEAR_VARS) -LOCAL_MODULE := libavutil -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavcodec -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavformat -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libswscale -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavfilter -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavdevice -LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so -LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libmpv -LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so -LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) - -LOCAL_MODULE := libplayer -LOCAL_CFLAGS := -Werror -LOCAL_CPPFLAGS += -std=c++11 -LOCAL_SRC_FILES := \ - main.cpp \ - render.cpp \ - log.cpp \ - jni_utils.cpp \ - property.cpp \ - event.cpp \ - thumbnail.cpp -LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic -LOCAL_SHARED_LIBRARIES := swscale avcodec mpv - -include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk deleted file mode 100644 index 53418cb4..00000000 --- a/app/src/main/jni/Application.mk +++ /dev/null @@ -1,17 +0,0 @@ -APP_ABI := -ifneq ($(PREFIX32),) -APP_ABI += armeabi-v7a -endif -ifneq ($(PREFIX64),) -APP_ABI += arm64-v8a -endif -ifneq ($(PREFIX_X64),) -APP_ABI += x86_64 -endif -ifneq ($(PREFIX_X86),) -APP_ABI += x86 -endif - -APP_PLATFORM := android-21 -APP_STL := c++_shared -APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true diff --git a/app/src/main/jni/README.md b/app/src/main/jni/README.md deleted file mode 100644 index 15d82972..00000000 --- a/app/src/main/jni/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# JNI bindings for libmpv - -The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni - -## License & copyright - -The files are copyrighted and used under MIT license below: - -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. diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp deleted file mode 100644 index b092b661..00000000 --- a/app/src/main/jni/event.cpp +++ /dev/null @@ -1,120 +0,0 @@ -#include <jni.h> - -#include <mpv/client.h> - -#include "globals.h" -#include "jni_utils.h" -#include "log.h" - -static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop) -{ - jstring jprop = env->NewStringUTF(prop->name); - jstring jvalue = NULL; - switch (prop->format) { - case MPV_FORMAT_NONE: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop); - break; - case MPV_FORMAT_FLAG: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop, - (jboolean) (*(int*)prop->data != 0)); - break; - case MPV_FORMAT_INT64: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop, - (jlong) *(int64_t*)prop->data); - break; - case MPV_FORMAT_DOUBLE: - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop, - (jdouble) *(double*)prop->data); - break; - case MPV_FORMAT_STRING: - jvalue = env->NewStringUTF(*(const char**)prop->data); - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue); - break; - default: - ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format); - break; - } - if (jprop) - env->DeleteLocalRef(jprop); - if (jvalue) - env->DeleteLocalRef(jvalue); -} - -static void sendEventToJava(JNIEnv *env, int event) -{ - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event); -} - -static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event) -{ - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error); -} - -static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg) -{ - // filter the most obvious cases of invalid utf-8, since Java would choke on it - const auto invalid_utf8 = [] (unsigned char c) { - return c == 0xc0 || c == 0xc1 || c >= 0xf5; - }; - for (int i = 0; msg->text[i]; i++) { - if (invalid_utf8(static_cast<unsigned char>(msg->text[i]))) - return; - } - - jstring jprefix = env->NewStringUTF(msg->prefix); - jstring jtext = env->NewStringUTF(msg->text); - - env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS, - jprefix, (jint) msg->log_level, jtext); - - if (jprefix) - env->DeleteLocalRef(jprefix); - if (jtext) - env->DeleteLocalRef(jtext); -} - -void *event_thread(void *arg) -{ - JNIEnv *env = NULL; - acquire_jni_env(g_vm, &env); - if (!env) - die("failed to acquire java env"); - - while (1) { - mpv_event *mp_event; - mpv_event_property *mp_property = NULL; - mpv_event_log_message *msg = NULL; - mpv_event_end_file *mp_end_file = NULL; - - mp_event = mpv_wait_event(g_mpv, -1.0); - - if (g_event_thread_request_exit) - break; - - if (mp_event->event_id == MPV_EVENT_NONE) - continue; - - switch (mp_event->event_id) { - case MPV_EVENT_LOG_MESSAGE: - msg = (mpv_event_log_message*)mp_event->data; - ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text); - sendLogMessageToJava(env, msg); - break; - case MPV_EVENT_PROPERTY_CHANGE: - mp_property = (mpv_event_property*)mp_event->data; - sendPropertyUpdateToJava(env, mp_property); - break; - case MPV_EVENT_END_FILE: - mp_end_file = (mpv_event_end_file*)mp_event->data; - sendEndFileEventToJava(env, mp_end_file); - default: - ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); - sendEventToJava(env, mp_event->event_id); - break; - } - } - - g_vm->DetachCurrentThread(); - - return NULL; -} diff --git a/app/src/main/jni/event.h b/app/src/main/jni/event.h deleted file mode 100644 index ebb20d2d..00000000 --- a/app/src/main/jni/event.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -void *event_thread(void *arg); diff --git a/app/src/main/jni/globals.h b/app/src/main/jni/globals.h deleted file mode 100644 index e2427b8b..00000000 --- a/app/src/main/jni/globals.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -#include <atomic> - -extern JavaVM *g_vm; -extern mpv_handle *g_mpv; -extern std::atomic<bool> g_event_thread_request_exit; diff --git a/app/src/main/jni/jni_utils.cpp b/app/src/main/jni/jni_utils.cpp deleted file mode 100644 index f878fdff..00000000 --- a/app/src/main/jni/jni_utils.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#define UTIL_EXTERN -#include "jni_utils.h" - -#include <jni.h> -#include <stdlib.h> - -bool acquire_jni_env(JavaVM *vm, JNIEnv **env) -{ - int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6); - if (ret == JNI_EDETACHED) - return vm->AttachCurrentThread(env, NULL) == 0; - else - return ret == JNI_OK; -} - -// Apparently it's considered slow to FindClass and GetMethodID every time we need them, -// so let's have a nice cache here. - -void init_methods_cache(JNIEnv *env) -{ - static bool methods_initialized = false; - if (methods_initialized) - return; - - #define FIND_CLASS(name) reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(name))) - java_Integer = FIND_CLASS("java/lang/Integer"); - java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V"); - java_Double = FIND_CLASS("java/lang/Double"); - java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V"); - java_Boolean = FIND_CLASS("java/lang/Boolean"); - java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V"); - - android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap"); - // createBitmap(int[], int, int, android.graphics.Bitmap$Config) - android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;"); - android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config"); - // static final android.graphics.Bitmap$Config ARGB_8888 - android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); - - mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib"); - mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String) - mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean) - mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long) - mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double) - mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String) - mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int) - mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int) - mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String) - #undef FIND_CLASS - - methods_initialized = true; -} diff --git a/app/src/main/jni/jni_utils.h b/app/src/main/jni/jni_utils.h deleted file mode 100644 index ed9d025d..00000000 --- a/app/src/main/jni/jni_utils.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include <jni.h> - -#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name -#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__) - -bool acquire_jni_env(JavaVM *vm, JNIEnv **env); -void init_methods_cache(JNIEnv *env); - -#ifndef UTIL_EXTERN -#define UTIL_EXTERN extern -#endif - -UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean; -UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init; - -UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config; -UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap; -UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888; - -UTIL_EXTERN jclass mpv_MPVLib; -UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S, - mpv_MPVLib_eventProperty_Sb, - mpv_MPVLib_eventProperty_Sl, - mpv_MPVLib_eventProperty_Sd, - mpv_MPVLib_eventProperty_SS, - mpv_MPVLib_event, - mpv_MPVLib_end_file_event, - mpv_MPVLib_logMessage_SiS; diff --git a/app/src/main/jni/log.cpp b/app/src/main/jni/log.cpp deleted file mode 100644 index 240af1bc..00000000 --- a/app/src/main/jni/log.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include "log.h" - -#include <stdlib.h> - -void die(const char *msg) -{ - ALOGE("%s", msg); - exit(1); -} diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h deleted file mode 100644 index bef941b2..00000000 --- a/app/src/main/jni/log.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include <android/log.h> - -#define DEBUG 0 - -#define LOG_TAG "mpv" -#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -#if DEBUG -#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__) -#else -#define ALOGV(...) (void)0 -#endif - -__attribute__((noreturn)) void die(const char *msg); - -#define CHECK_MPV_INIT() do { \ - if (__builtin_expect(!g_mpv, 0)) \ - die("libmpv is not initialized"); \ - } while (0) diff --git a/app/src/main/jni/main.cpp b/app/src/main/jni/main.cpp deleted file mode 100644 index 5f3faedc..00000000 --- a/app/src/main/jni/main.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include <jni.h> -#include <stdlib.h> -#include <stdio.h> -#include <time.h> -#include <locale.h> -#include <atomic> - -#include <mpv/client.h> - -#include <pthread.h> - -extern "C" { - #include <libavcodec/jni.h> -} - -#include "log.h" -#include "jni_utils.h" -#include "event.h" - -#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0])) - -extern "C" { - jni_func(void, create, jobject appctx); - jni_func(void, init); - jni_func(void, destroy); - - jni_func(void, command, jobjectArray jarray); -}; - -JavaVM *g_vm; -mpv_handle *g_mpv; -std::atomic<bool> g_event_thread_request_exit(false); - -static pthread_t event_thread_id; - -static void prepare_environment(JNIEnv *env, jobject appctx) { - setlocale(LC_NUMERIC, "C"); - - if (!env->GetJavaVM(&g_vm) && g_vm) - av_jni_set_java_vm(g_vm, NULL); - - jobject global_appctx = env->NewGlobalRef(appctx); - if (global_appctx) - av_jni_set_android_app_ctx(global_appctx, NULL); - - init_methods_cache(env); -} - -jni_func(void, create, jobject appctx) { - prepare_environment(env, appctx); - - if (g_mpv) - die("mpv is already initialized"); - - g_mpv = mpv_create(); - if (!g_mpv) - die("context init failed"); - - // use terminal log level but request verbose messages - // this way --msg-level can be used to adjust later - mpv_request_log_messages(g_mpv, "terminal-default"); - mpv_set_option_string(g_mpv, "msg-level", "all=v"); -} - -jni_func(void, init) { - if (!g_mpv) - die("mpv is not created"); - - if (mpv_initialize(g_mpv) < 0) - die("mpv init failed"); - - g_event_thread_request_exit = false; - if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) - die("thread create failed"); - pthread_setname_np(event_thread_id, "event_thread"); -} - -jni_func(void, destroy) { - if (!g_mpv) { - ALOGV("mpv destroy called but it's already destroyed"); - return; - } - - // poke event thread and wait for it to exit - g_event_thread_request_exit = true; - mpv_wakeup(g_mpv); - pthread_join(event_thread_id, NULL); - - mpv_terminate_destroy(g_mpv); - g_mpv = NULL; -} - -jni_func(void, command, jobjectArray jarray) { - CHECK_MPV_INIT(); - - const char *arguments[128] = {0}; - int len = env->GetArrayLength(jarray); - if (len >= ARRAYLEN(arguments)) - die("too many command arguments"); - - for (int i = 0; i < len; ++i) - arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL); - - mpv_command(g_mpv, arguments); - - for (int i = 0; i < len; ++i) - env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]); -} diff --git a/app/src/main/jni/property.cpp b/app/src/main/jni/property.cpp deleted file mode 100644 index a9558ab6..00000000 --- a/app/src/main/jni/property.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include <jni.h> -#include <stdlib.h> - -#include <mpv/client.h> - -#include "jni_utils.h" -#include "log.h" -#include "globals.h" - -extern "C" { - jni_func(jint, setOptionString, jstring option, jstring value); - - jni_func(jobject, getPropertyInt, jstring property); - jni_func(void, setPropertyInt, jstring property, jint value); - jni_func(jobject, getPropertyDouble, jstring property); - jni_func(void, setPropertyDouble, jstring property, jdouble value); - jni_func(jobject, getPropertyBoolean, jstring property); - jni_func(void, setPropertyBoolean, jstring property, jboolean value); - jni_func(jstring, getPropertyString, jstring jproperty); - jni_func(void, setPropertyString, jstring jproperty, jstring jvalue); - - jni_func(void, observeProperty, jstring property, jint format); -} - -jni_func(jint, setOptionString, jstring joption, jstring jvalue) { - CHECK_MPV_INIT(); - - const char *option = env->GetStringUTFChars(joption, NULL); - const char *value = env->GetStringUTFChars(jvalue, NULL); - - int result = mpv_set_option_string(g_mpv, option, value); - - env->ReleaseStringUTFChars(joption, option); - env->ReleaseStringUTFChars(jvalue, value); - - return result; -} - -static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output) -{ - CHECK_MPV_INIT(); - - const char *prop = env->GetStringUTFChars(jproperty, NULL); - int result = mpv_get_property(g_mpv, prop, format, output); - if (result == MPV_ERROR_PROPERTY_UNAVAILABLE) - ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format); - else if (result < 0) - ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(jproperty, prop); - - return result; -} - -static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value) -{ - CHECK_MPV_INIT(); - - const char *prop = env->GetStringUTFChars(jproperty, NULL); - int result = mpv_set_property(g_mpv, prop, format, value); - if (result < 0) - ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(jproperty, prop); - - return result; -} - -jni_func(jobject, getPropertyInt, jstring jproperty) { - int64_t value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) - return NULL; - return env->NewObject(java_Integer, java_Integer_init, (jint)value); -} - -jni_func(jobject, getPropertyDouble, jstring jproperty) { - double value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) - return NULL; - return env->NewObject(java_Double, java_Double_init, (jdouble)value); -} - -jni_func(jobject, getPropertyBoolean, jstring jproperty) { - int value = 0; - if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) - return NULL; - return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value); -} - -jni_func(jstring, getPropertyString, jstring jproperty) { - char *value; - if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) - return NULL; - jstring jvalue = env->NewStringUTF(value); - mpv_free(value); - return jvalue; -} - -jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) { - int64_t value = static_cast<int64_t>(jvalue); - common_set_property(env, jproperty, MPV_FORMAT_INT64, &value); -} - -jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) { - double value = static_cast<double>(jvalue); - common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value); -} - -jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) { - int value = jvalue == JNI_TRUE ? 1 : 0; - common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value); -} - -jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) { - const char *value = env->GetStringUTFChars(jvalue, NULL); - common_set_property(env, jproperty, MPV_FORMAT_STRING, &value); - env->ReleaseStringUTFChars(jvalue, value); -} - -jni_func(void, observeProperty, jstring property, jint format) { - CHECK_MPV_INIT(); - const char *prop = env->GetStringUTFChars(property, NULL); - int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format); - if (result < 0) - ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); - env->ReleaseStringUTFChars(property, prop); -} diff --git a/app/src/main/jni/render.cpp b/app/src/main/jni/render.cpp deleted file mode 100644 index 1df2bb5a..00000000 --- a/app/src/main/jni/render.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include <jni.h> - -#include <mpv/client.h> - -#include "jni_utils.h" -#include "log.h" -#include "globals.h" - -extern "C" { - jni_func(void, attachSurface, jobject surface_); - jni_func(void, detachSurface); -}; - -static jobject surface; - -jni_func(void, attachSurface, jobject surface_) { - CHECK_MPV_INIT(); - - surface = env->NewGlobalRef(surface_); - if (!surface) - die("invalid surface provided"); - int64_t wid = reinterpret_cast<intptr_t>(surface); - int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); - if (result < 0) - ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); -} - -jni_func(void, detachSurface) { - CHECK_MPV_INIT(); - - int64_t wid = 0; - int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); - if (result < 0) - ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); - - env->DeleteGlobalRef(surface); - surface = NULL; -} diff --git a/app/src/main/jni/thumbnail.cpp b/app/src/main/jni/thumbnail.cpp deleted file mode 100644 index d1156f8e..00000000 --- a/app/src/main/jni/thumbnail.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include <stdlib.h> -#include <string> - -#include <jni.h> -#include <android/bitmap.h> -#include <mpv/client.h> - -extern "C" { - #include <libswscale/swscale.h> -}; - -#include "jni_utils.h" -#include "globals.h" -#include "log.h" - -extern "C" { - jni_func(jobject, grabThumbnail, jint dimension); -}; - -static inline mpv_node make_node_str(const char *s) -{ - mpv_node r{}; - r.format = MPV_FORMAT_STRING; - r.u.string = const_cast<char*>(s); - return r; -} - -jni_func(jobject, grabThumbnail, jint dimension) { - CHECK_MPV_INIT(); - - mpv_node result{}; - { - mpv_node c{}, c_args[2]; - mpv_node_list c_array{}; - c_args[0] = make_node_str("screenshot-raw"); - c_args[1] = make_node_str("video"); - c_array.num = 2; - c_array.values = c_args; - c.format = MPV_FORMAT_NODE_ARRAY; - c.u.list = &c_array; - if (mpv_command_node(g_mpv, &c, &result) < 0) { - ALOGE("screenshot-raw command failed"); - return NULL; - } - } - - // extract relevant property data from the node map mpv returns - int w = 0, h = 0, stride = 0; - bool format_ok = false; - struct mpv_byte_array *data = NULL; - do { - if (result.format != MPV_FORMAT_NODE_MAP) - break; - for (int i = 0; i < result.u.list->num; i++) { - std::string key(result.u.list->keys[i]); - const mpv_node *val = &result.u.list->values[i]; - if (key == "w" || key == "h" || key == "stride") { - if (val->format != MPV_FORMAT_INT64) - break; - if (key == "w") - w = val->u.int64; - else if (key == "h") - h = val->u.int64; - else - stride = val->u.int64; - } else if (key == "format") { - if (val->format != MPV_FORMAT_STRING) - break; - format_ok = !strcmp(val->u.string, "bgr0"); - } else if (key == "data") { - if (val->format != MPV_FORMAT_BYTE_ARRAY) - break; - data = val->u.ba; - } - } - } while (0); - if (!w || !h || !stride || !format_ok || !data) { - ALOGE("extracting data failed"); - mpv_free_node_contents(&result); - return NULL; - } - ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride); - - // crop to square - int crop_left = 0, crop_top = 0; - int new_w = w, new_h = h; - if (w > h) { - crop_left = (w - h) / 2; - new_w = h; - } else { - crop_top = (h - w) / 2; - new_h = w; - } - ALOGV("cropped w:%u h:%u", new_w, new_h); - - uint8_t *new_data = reinterpret_cast<uint8_t*>(data->data); - new_data += crop_left * sizeof(uint32_t); // move begin rightwards - new_data += stride * crop_top; // move begin downwards - - // convert & scale to appropriate size - struct SwsContext *ctx = sws_getContext( - new_w, new_h, AV_PIX_FMT_BGR0, - dimension, dimension, AV_PIX_FMT_RGB32, - SWS_BICUBIC, NULL, NULL, NULL); - if (!ctx) { - mpv_free_node_contents(&result); - return NULL; - } - - jintArray arr = env->NewIntArray(dimension * dimension); - jint *scaled = env->GetIntArrayElements(arr, NULL); - - uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled }; - int src_stride[4] = { stride }, - dst_stride[4] = { (int) sizeof(jint) * dimension }; - sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride); - sws_freeContext(ctx); - - mpv_free_node_contents(&result); // frees data->data - - // create android.graphics.Bitmap - env->ReleaseIntArrayElements(arr, scaled, 0); - - jobject bitmap_config = - env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888); - jobject bitmap = - env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap, - arr, dimension, dimension, bitmap_config); - env->DeleteLocalRef(arr); - env->DeleteLocalRef(bitmap_config); - - return bitmap; -} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 737dbb57..910853e9 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -6,6 +6,7 @@ option java_multiple_files = true; enum ShowNextUpWhen{ END_OF_PLAYBACK = 0; DURING_CREDITS = 1; + NEXT_UP_NEVER = 2; } enum SkipSegmentBehavior{ @@ -33,6 +34,7 @@ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; PREFER_MPV = 2; + EXTERNAL_PLAYER = 3; } message MpvOptions{ @@ -40,14 +42,27 @@ message MpvOptions{ bool use_gpu_next = 2; } +enum AssPlaybackMode{ + ASS_LIBASS = 0; + ASS_EXO_PLAYER = 1; + ASS_TRANSCODE = 2; +} + message PlaybackOverrides{ bool ac3_supported = 1; bool downmix_stereo = 2; - bool direct_play_ass = 3; + 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 { @@ -76,13 +91,23 @@ message PlaybackPreferences { MpvOptions mpv_options = 21; bool refresh_rate_switching = 22; 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 { @@ -101,6 +126,9 @@ enum AppThemeColors { ORANGE = 3; BOLD_BLUE = 4; OLED_BLACK = 5; + RED = 6; + BROWN = 7; + reserved 8; } enum EdgeStyle{ @@ -129,6 +157,7 @@ message SubtitlePreferences{ int32 margin = 11; int32 edge_thickness = 12; int32 image_subtitle_opacity = 13; + bool use_separate_hdr = 14; } message LiveTvPreferences { @@ -144,10 +173,33 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +message ScreensaverPreferences{ + int64 start_delay = 1; + int64 duration = 2; + bool animate = 3; + int32 max_age_filter = 4; + 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; @@ -155,6 +207,11 @@ message InterfacePreferences { LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; + ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; + bool show_logos = 13; + SearchPreferences search_preferences = 14; + repeated DisplayToggle display_toggles = 15; } message AdvancedPreferences { @@ -166,10 +223,31 @@ message PhotoPreferences{ bool slideshow_play_videos = 2; } +message MusicPreferences { + bool show_lyrics = 1; + bool show_visualizer = 2; + bool show_album_Art = 3; + 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; @@ -182,4 +260,6 @@ message AppPreferences { AdvancedPreferences advanced_preferences = 10; 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 new file mode 100644 index 00000000..6db20221 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,193 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">حول</string> + <string name="active_recordings">التسجيلات النشطة</string> + <string name="add_favorite">إضافة للمفضلة</string> + <string name="add_server">إضافة خادم</string> + <string name="add_user">إضافة مستخدم</string> + <string name="audio">الصوت</string> + <string name="birthplace">مكان الميلاد</string> + <string name="bitrate">معدل البت</string> + <string name="born">وُلد</string> + <string name="cancel_recording">إلغاء التسجيل</string> + <string name="cancel_series_recording">إلغاء تسجيل المسلسل</string> + <string name="cancel">إلغاء</string> + <string name="chapters">الفصول</string> + <string name="choose_stream">اختيار %1$s</string> + <string name="clear_image_cache">مسح ذاكرة التخزين المؤقت للصور</string> + <string name="collection">مجموعة</string> + <string name="collections">المجموعات</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="critic_rating">تقييم النقاد</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="director">المخرج</string> + <string name="disabled">مُعطّل</string> + <string name="discovered_servers">الخوادم المكتشفة</string> + <string name="download_and_update">تنزيل وتحديث</string> + <string name="downloading">تنزيل…</string> + <string name="enabled">مُفعّل</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> + <string name="error_loading_collection">خطأ في تحميل المجموعة %1$s</string> + <string name="external_track">خارجي</string> + <string name="favorites">المفضلة</string> + <string name="file_size">الحجم</string> + <string name="forced_track">إجبارية</string> + <string name="genres">الأنواع</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> + <string name="home">الرئيسية</string> + <string name="immediate">فوري</string> + <string name="intro">المقدمة</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">المكتبة</string> + <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_unwatched">تعيين كغير مشاهد</string> + <string name="mark_watched">تعيين كمشاهد</string> + <string name="max_bitrate">الحد الأقصى لمعدل البت</string> + <string name="more_like_this">المزيد من هذا القبيل</string> + <string name="more">المزيد</string> + <plurals name="movies"> + <item quantity="zero">الأفلام</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_servers_found">لم يتم العثور على خوادم</string> + <string name="no_scheduled_recordings">لا توجد تسجيلات مجدولة</string> + <string name="no_update_available">لا يوجد تحديث متاح</string> + <string name="none">لا شيء</string> + <string name="only_forced_subtitles">الترجمات الإجبارية فقط</string> + <string name="outro">خاتمة</string> + <string name="path">المسار</string> + <plurals name="people"> + <item quantity="zero">الأشخاص</item> + <item quantity="other">الأشخاص</item> + </plurals> + <string name="play_count">عدد مرات التشغيل</string> + <string name="play_from_here">تشغيل من هنا</string> + <string name="play">تشغيل</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="preview">معاينة</string> + <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_recorded">التسجيلات الأخيرة</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="restart">إعادة تشغيل</string> + <string name="resume">استئناف</string> + <string name="save">حفظ</string> + <string name="search_and_download">بحث وتنزيل</string> + <string name="search">بحث</string> + <string name="searching">جارٍ البحث…</string> + <string name="voice_search">البحث الصوتي</string> + <string name="voice_starting">جارٍ البدء</string> + <string name="voice_search_prompt">تحدث للبحث</string> + <string name="processing">جارٍ المعالجة</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_no_match">لم يتم التعرف على الكلام</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="select_server">اختر الخادم</string> + <string name="select_user">اختر المستخدم</string> + <string name="show_debug_info">إظهار معلومات تصحيح الأخطاء</string> + <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="sort_by_date_played">تاريخ التشغيل</string> + <string name="sort_by_date_released">تاريخ الإصدار</string> + <string name="sort_by_name">الاسم</string> + <string name="sort_by_random">عشوائي</string> + <string name="studios">استوديوهات</string> + <string name="submit">إرسال</string> + <string name="suggestions">اقتراحات</string> + <string name="switch_servers">بدّل الخوادم</string> + <string name="version">إصدار</string> + <string name="tv_seasons">مواسم</string> + <string name="tv_season">موسم</string> + <string name="tv_guide">دليل</string> + <string name="watch_live">مشاهدة مباشرة</string> + <string name="background">الخلفية</string> + <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_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 1a328c33..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> @@ -401,6 +376,7 @@ <string name="clear_track_choices">Vyčistit výběr skladeb</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">True HD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -422,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> @@ -444,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> @@ -475,4 +447,140 @@ <string name="slideshow_duration">Trvání prezentace</string> <string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string> <string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string> + <plurals name="days"> + <item quantity="one">%s den</item> + <item quantity="few">%s dny</item> + <item quantity="many">%s dní</item> + <item quantity="other">%s dní</item> + </plurals> + <string name="no_limit">Bez omezení</string> + <string name="max_days_next_up">Max. počet dnů v Dalších v pořadí</string> + <string name="quick_connect">Rychlé připojení</string> + <string name="quick_connect_summary">Autorizovat jiné zařízení k přihlášení k vašemu účtu</string> + <string name="quick_connect_code">Zadejte kód Rychlého připojení</string> + <string name="quick_connect_code_error">Kód musí mít 6 číslic</string> + <string name="quick_connect_success">Zařízení úspěšně autorizováno</string> + <string name="add_row">Přidat řádek</string> + <string name="genres_in">Žánry v %1$s</string> + <string name="recently_released_in">Nedávno vydáno v %1$s</string> + <string name="height">Výška</string> + <string name="apply_all_rows">Použít na všechny řádky</string> + <string name="customize_home">Přizpůsobit domovskou stránku</string> + <string name="home_rows">Řádky na domovské stránce</string> + <string name="load_from_server">Načíst ze serverového uživatelského profilu</string> + <string name="save_to_server">Uložit do serverového uživatelského profilu</string> + <string name="load_from_web_client">Načíst z webového klienta</string> + <string name="use_series">Použít obrázek seriálu</string> + <string name="add_row_for">Přidat řádek pro %1$s</string> + <string name="overwrite_server_settings">Přepsat nastavení na serveru?</string> + <string name="overwrite_local_settings">Přepsat místní nastavení?</string> + <string name="for_episodes">Pro epizody</string> + <string name="suggestions_for">Návrhy na %1$s</string> + <string name="increase_all_cards_size">Zvětšit velikost všech karet</string> + <string name="decrease_all_cards_size">Zmenšit velikost všech karet</string> + <string name="use_thumb_images">Použít obrázky miniatur</string> + <string name="settings_saved">Nastavení uložena</string> + <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_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 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> + <string name="volume_low">Nízká</string> + <string name="volume_medium">Střední</string> + <string name="volume_high">Vysoká</string> + <string name="volume_full">Plná</string> + <string name="purple">Fialová</string> + <string name="orange">Oranžová</string> + <string name="bold_blue">Odvážná modrá</string> + <string name="black">Černá</string> + <string name="skip_ignore">Ignorovat</string> + <string name="skip_automatically">Automaticky přeskočit</string> + <string name="skip_ask">Zeptat se</string> + <string name="ffmpeg_fallback">Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér</string> + <string name="ffmpeg_prefer">Preferovat FFmpeg nad vestavěnými dekodéry</string> + <string name="ffmpeg_never">Nikdy nepoužívat dekodéry FFmpeg</string> + <string name="next_up_playback_end">Na konci přehrávání</string> + <string name="next_up_outro">Během titulků</string> + <string name="white">Bílá</string> + <string name="light_gray">Světle šedá</string> + <string name="dark_gray">Tmavě šedá</string> + <string name="yellow">Žlutá</string> + <string name="cyan">Azurová</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Okrajová linka</string> + <string name="subtitle_edge_shadow">Stín</string> + <string name="background_style_wrap">Zabalené</string> + <string name="background_style_boxed">Krabicové</string> + <string name="prefer_mpv">Preferovat MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Použít ExoPlayer pro přehrávání HDR obsahu</string> + <string name="aspect_ratios_poster">Plakát (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">Čtverec (1:1)</string> + <string name="image_type_primary">Primární</string> + <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">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">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 new file mode 100644 index 00000000..a6efeb91 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,712 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Om</string> + <string name="active_recordings">Aktive optagelser</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Tilføj server</string> + <string name="add_user">Tilføj bruger</string> + <string name="audio">Lyd</string> + <string name="birthplace">Fødested</string> + <string name="bitrate">Bitrate</string> + <string name="born">Født</string> + <string name="cancel_recording">Annullér optagelse</string> + <string name="cancel_series_recording">Annullér serieoptagelse</string> + <string name="cancel">Annullér</string> + <string name="chapters">Kapitler</string> + <string name="choose_stream">Vælg %1$s</string> + <string name="clear_image_cache">Ryd billedcache</string> + <string name="collection">Samling</string> + <string name="collections">Samlinger</string> + <string name="community_rating">Brugerbedømmelse</string> + <string name="compose_error_message_title">Der opstod en fejl! Tryk på knappen for at sende logfiler til din server.</string> + <string name="confirm">Bekræft</string> + <string name="continue_watching">Fortsæt med at se</string> + <string name="critic_rating">Kritikerbedømmelse</string> + <string name="decimal_seconds">%.2f sekunder</string> + <string name="default_track">Standard</string> + <string name="delete">Slet</string> + <string name="died">Død</string> + <string name="directed_by">Instrueret af %1$s</string> + <string name="director">Instruktør</string> + <string name="disabled">Deaktiveret</string> + <string name="discovered_servers">Opdagede servere</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> + <string name="enter_server_url">Indtast server-IP eller URL</string> + <string name="enter_server_address">Indtast serveradresse</string> + <string name="episodes">Episoder</string> + <string name="error_loading_collection">Fejl ved indlæsning af samling %1$s</string> + <string name="external_track">Ekstern</string> + <string name="favorites">Favoritter</string> + <string name="file_size">Størrelse</string> + <string name="forced_track">Tvunget</string> + <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 afspilningskontroller</string> + <string name="hide_debug_info">Skjul fejlfindingsoplysninger</string> + <string name="hide">Skjul</string> + <string name="home">Hjem</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 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> + <string name="more_like_this">Mere som dette</string> + <string name="more">Mere</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Film</item> + </plurals> + <string name="name">Navn</string> + <string name="next_up">Næste</string> + <string name="no_data">Ingen data</string> + <string name="no_results">Ingen resultater</string> + <string name="no_servers_found">Ingen servere fundet</string> + <string name="no_scheduled_recordings">Ingen planlagte optagelser</string> + <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">Outro</string> + <string name="path">Filsti</string> + <plurals name="people"> + <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 fejlfindingsoplysninger for afspilning</string> + <string name="playback_speed">Afspilningshastighed</string> + <string name="playback">Afspilning</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> + <string name="recap">Opsummering</string> + <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 udgivet</string> + <string name="recommended">Anbefalet</string> + <string name="record_program">Optag program</string> + <string name="record_series">Optag serie</string> + <string name="remove_favorite">Fjern fra favoritter</string> + <string name="restart">Genstart</string> + <string name="resume">Fortsæt</string> + <string name="save">Gem</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">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> + <string name="voice_error_permissions">Mikrofontilladelse kræves</string> + <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">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> + <string name="voice_error_start_failed">Kunne ikke starte stemmegenkendelse</string> + <string name="voice_error_timeout">Tidsgrænsen for stemmegenkendelse er overskredet</string> + <string name="retry">Prøv igen</string> + <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">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 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> + <string name="sort_by_random">Tilfældig</string> + <string name="studios">Studier</string> + <string name="submit">Indsend</string> + <string name="subtitle_download_too_long">Downloaden tager lang tid, du skal muligvis genstarte afspilningen</string> + <string name="subtitle">Undertekst</string> + <string name="subtitles">Undertekster</string> + <string name="suggestions">Forslag</string> + <string name="switch_servers">Skift server</string> + <string name="switch_user">Skift</string> + <string name="top_unwatched">Bedst bedømte usete</string> + <plurals name="trailers"> + <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-serie</item> + <item quantity="other">TV-serier</item> + </plurals> + <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 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">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> + <string name="background">Baggrund</string> + <string name="success">Succes</string> + <string name="official_rating">Aldersgrænse</string> + <string name="runtime_sort">Spilletid</string> + <string name="extras_title">Ekstra</string> + <plurals name="other_extras"> + <item quantity="one">Andet</item> + <item quantity="other">Andet</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Bag scenen</item> + <item quantity="other">Bag scenerne</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Temasang</item> + <item quantity="other">Temasange</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Temavideo</item> + <item quantity="other">Temavideoer</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Klip</item> + <item quantity="other">Klip</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Slettet scene</item> + <item quantity="other">Slettede scener</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Interview</item> + <item quantity="other">Interviews</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scene</item> + <item quantity="other">Scener</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Smagsprøve</item> + <item quantity="other">Smagsprøver</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Featurette</item> + <item quantity="other">Featurettes</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kort</item> + <item quantity="other">Kort</item> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s download</item> + <item quantity="other">%s downloads</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d time</item> + <item quantity="other">%d timer</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dage</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d element</item> + <item quantity="other">%d elementer</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekund</item> + <item quantity="other">%d sekunder</item> + </plurals> + <string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string> + <string name="advanced_settings">Avancerede indstillinger</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 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 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 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">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">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">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 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">Hvordan reklamer springes over</string> + <string name="skip_forward_preference">Spring fremad</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, der bruges til at søge efter appopdateringer</string> + <string name="update_url">URL til opdatering</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">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">Afspilningsmotor</string> + <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</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> + <string name="decade">Årti</string> + <string name="remove">Fjern</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <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">Margen</string> + <string name="verbose_logging">Detaljeret logning</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 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-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">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[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 og adgangskode</string> + <string name="login">Log ind</string> + <string name="general">Generelt</string> + <string name="container">Container</string> + <string name="title">Titel</string> + <string name="codec">Codec</string> + <string name="profile">Profil</string> + <string name="level">Niveau</string> + <string name="resolution">Opløsning</string> + <string name="anamorphic">Anamorfisk</string> + <string name="interlaced">Sammenflettet</string> + <string name="framerate">Billedfrekvens</string> + <string name="bit_depth">Bitdybde</string> + <string name="video_range">Videoområde</string> + <string name="video_range_type">Type af videoområde</string> + <string name="color_space">Farverum</string> + <string name="color_transfer">Farveoverførsel</string> + <string name="color_primaries">Primære farver</string> + <string name="pixel_format">Pixel-format</string> + <string name="ref_frames">Referencerammer</string> + <string name="nal">NAL</string> + <string name="language">Sprog</string> + <string name="layout">Layout</string> + <string name="channels">Kanaler</string> + <string name="sample_rate">Samplingsfrekvens</string> + <string name="avc">AVC</string> + <string name="yes">Ja</string> + <string name="no">Nej</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">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 senest sete</string> + <string name="color_code_programs">Farvekod programmer</string> + <string name="subtitle_delay">Undertekstforsinkelse</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 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> + <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">Afspil Dolby Vision-profil 7 direkte</string> + <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</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="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 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">Ø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> + <string name="contrast">Kontrast</string> + <string name="saturation">Mætning</string> + <string name="hue">Farvetone</string> + <string name="red">Rød</string> + <string name="green">Grøn</string> + <string name="blue">Blå</string> + <string name="blur">Sløring</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> + <string name="no_more_images">Ikke flere billeder</string> + <string name="rotate_left">Roter til venstre</string> + <string name="rotate_right">Roter til højre</string> + <string name="zoom_in">Zoom ind</string> + <string name="zoom_out">Zoom ud</string> + <string name="slideshow_duration">Diasshowvarighed</string> + <string name="play_videos_during_slideshow">Afspil videoer under diasshow</string> + <string name="send_media_info_log_to_server">Send medieinformationslog til serveren</string> + <string name="no_limit">Ingen grænse</string> + <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 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 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> + <string name="overwrite_server_settings">Overskriv indstillinger på serveren?</string> + <string name="overwrite_local_settings">Overskriv lokale indstillinger?</string> + <string name="for_episodes">For episoder</string> + <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 miniaturebilleder</string> + <string name="settings_saved">Indstillinger gemt</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 eddd471d..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> @@ -19,20 +19,20 @@ <string name="confirm">Bestätigen</string> <string name="continue_watching">Weiterschauen</string> <string name="critic_rating">Kritiken</string> - <string name="decimal_seconds">%.1f Sekunden</string> + <string name="decimal_seconds">%.2f Sekunden</string> <string name="default_track">Standard</string> <string name="delete">Löschen</string> <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">Personen</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="compose_error_message_title">Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden.</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> @@ -118,11 +120,11 @@ <string name="next_up">Als nächstes</string> <string name="none">Keine</string> <string name="play_from_here">Von hier wiedergeben</string> - <string name="play">Wiedergeben</string> + <string name="play">Abspielen</string> <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,23 +132,23 @@ <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">Zufall</string> + <string name="shuffle">Zufällig</string> <string name="skip">Überspringen</string> - <string name="sort_by_date_added">Datum hinzugefügt</string> - <string name="sort_by_date_episode_added">Datum Episode hinzugefügt</string> - <string name="sort_by_date_played">Datum abgespielt</string> + <string name="sort_by_date_added">Hinzugefügt am</string> + <string name="sort_by_date_episode_added">Episode hinzugefügt am</string> + <string name="sort_by_date_played">Wiedergegeben am</string> <string name="sort_by_date_released">Erscheinungsdatum</string> <string name="sort_by_random">Zufällig</string> - <string name="submit">Absenden</string> - <string name="subtitle_download_too_long">Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten</string> + <string name="submit">Übermitteln</string> + <string name="subtitle_download_too_long">Herunterladen dauert sehr lange, vielleicht hilft ein Neustart der Wiedergabe</string> <string name="subtitle">Untertitel</string> <string name="suggestions">Vorschläge</string> <string name="switch_user">Wechseln</string> - <string name="top_unwatched">Top Bewertet Ungesehen</string> - <string name="tv_guide">Fernsehprogramm</string> - <string name="play_with_transcoding">Mit Transkodierung abspielen</string> + <string name="top_unwatched">Am besten bewertet ungesehen</string> + <string name="tv_guide">Guide</string> + <string name="play_with_transcoding">Mit Transcoding wiedergeben</string> <string name="show_clock">Uhr anzeigen</string> <string name="create_playlist">Neue Playlist erstellen</string> <string name="add_to_playlist">Zur Playlist hinzufügen</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,36 +177,36 @@ <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">Zeige Wiedergabe Debug infos</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> <string name="show">Zeigen</string> <string name="sort_by_name">Name</string> <string name="studios">Studios</string> - <string name="tv_dvr_schedule">DVR Zeitplan</string> - <string name="video_scale">Videogröße</string> + <string name="tv_dvr_schedule">Aufnahmeplan</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"> - <item quantity="one">Theme Songs</item> - <item quantity="other"/> + <item quantity="one">Titelsong</item> + <item quantity="other">Titelsongs</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Theme Videos</item> - <item quantity="other"/> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideos</item> </plurals> <plurals name="clips"> <item quantity="one">Clip</item> @@ -215,71 +217,71 @@ <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">Shorts</item> - <item quantity="other"/> + <item quantity="one">Short</item> + <item quantity="other">Shorts</item> </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> - <string name="direct_play_ass">Direct play ASS subtitles</string> - <string name="direct_play_pgs">Direct play PGS subtitles</string> + <string name="direct_play_ass">Libass für ASS Untertitel verwenden</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> <string name="font_size">Schriftgröße</string> <string name="font_color">Schriftfarbe</string> - <string name="font_opacity">Schriftart Deckkraft</string> - <string name="edge_style">Rand Stil</string> - <string name="edge_color">Rand Farbe</string> - <string name="background_opacity">Hintergrund Deckktraft</string> - <string name="background_style">Hintergrund Stil</string> - <string name="subtitle_style">Untertitel Stil</string> + <string name="font_opacity">Deckkraft der Schrift</string> + <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">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> @@ -321,15 +323,15 @@ <string name="no">Nein</string> <string name="show_titles">Titel anzeigen</string> <string name="live_tv_repeat">Wiederholen</string> - <string name="general">Generell</string> + <string name="general">Allgemein</string> <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> - <string name="cast_and_crew">Besetzung & Mitwirkende</string> + <string name="cast_and_crew"><![CDATA[Besetzung & Mitwirkende]]></string> <string name="container">Container</string> <string name="codec">Codec</string> <string name="level">Level</string> @@ -344,58 +346,59 @@ <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> <string name="request_4k">In 4K anfragen</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="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> @@ -403,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> @@ -417,4 +420,343 @@ <string name="stop_slideshow">Diashow anhalten</string> <string name="brightness">Helligkeit</string> <string name="contrast">Kontrast</string> + <string name="zoom_out">Verkleinern</string> + <string name="zoom_in">Vergrößern</string> + <string name="quick_connect_success">Gerät erfolgreich authorisiert</string> + <string name="saturation">Sättigung</string> + <string name="hue">Farbton</string> + <plurals name="days"> + <item quantity="one">%s Tag</item> + <item quantity="other">%s Tage</item> + </plurals> + <string name="quick_connect_code_error">Der Code muss aus 6 Ziffern bestehen</string> + <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">Max. Tage in \"Als Nächstes\"</string> + <string name="quick_connect">Quick Connect</string> + <plurals name="minutes"> + <item quantity="one">%s Minute</item> + <item quantity="other">%s Minuten</item> + </plurals> + <string name="interlaced">Interlaced</string> + <string name="nal">NAL</string> + <string name="quick_connect_summary">Gewähre anderen Geräten Zugang zu deinem Account</string> + <string name="quick_connect_code">Quick Connect-Code eingeben</string> + <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="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 ü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> + <string name="customize_home">Anpassung Homepage</string> + <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 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">Bis %s Jahre</string> + <string name="screensaver">Bildschirmschoner</string> + <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> + <string name="start_screensaver">Bildschirmschoner starten</string> + <string name="duration">Dauer</string> + <string name="photos">Fotos</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> + <string name="skip_ignore">Ignorieren</string> + <string name="skip_automatically">Automatisch überspringen</string> + <string name="skip_ask">Vor dem Überspringen nachfragen</string> + <string name="ffmpeg_fallback">FFmpeg nur verwenden, sofern keine anderen Decoder vorhanden sind</string> + <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 des Abspanns</string> + <string name="white">Weiß</string> + <string name="light_gray">Hellgrau</string> + <string name="dark_gray">Dunkelgrau</string> + <string name="yellow">Gelb</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <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 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="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">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_high">Hoch</string> + <string name="volume_full">Voll</string> + <string name="purple">Lila</string> + <string name="orange">Orange</string> + <string name="black">Schwarz</string> + <string name="backdrop_style_image">Nur Bild</string> + <string name="series_continueing">Aktuell</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">Darsteller</string> + <string name="composer">Komponist</string> + <string name="writer">Autor</string> + <string name="guest_star">Gastdarsteller</string> + <string name="producer">Produzent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Liedtexter</string> + <string name="artist">Künstler</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> + <string name="no_subtitles_found">Es wurden keine Untertitel gefunden</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingenieur</string> + <string name="bold_blue">Dunkelblau</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-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> + <string name="background_style_boxed">Kasten</string> + <string name="mixer">Mischer</string> + <string name="select_all">Alle auswählen</string> + <string name="albums">Alben</string> + <string name="artists">Interpreten</string> + <string name="songs">Lieder</string> + <string name="go_to_artist">Zu Interpret wechseln</string> + <string name="now_playing">Läuft gerade</string> + <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">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="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">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">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="view_more">Mehr anzeigen</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="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 (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="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 31445b5a..714ae611 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,10 +12,10 @@ <string name="choose_stream">Elegir %1$s</string> <string name="collection">Colección</string> <string name="collections">Colecciones</string> - <string name="commercial">Comercial</string> + <string name="commercial">Anuncio</string> <string name="confirm">Confirmar</string> <string name="continue_watching">Continuar viendo</string> - <string name="decimal_seconds">%.1f segundos</string> + <string name="decimal_seconds">%.2f segundos</string> <string name="delete">Eliminar</string> <string name="died">Fallecido</string> <string name="enabled">Activado</string> @@ -35,8 +35,8 @@ <string name="name">Nombre</string> <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> - <string name="outro">Outro</string> - <string name="people">Personas</string> + <string name="outro">Créditos finales</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,31 +63,34 @@ <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> <string name="add_to_playlist">Agregar a la lista de reproducción</string> <string name="one_click_pause">Pausar con un clic</string> - <string name="one_click_pause_summary_on">Presiona el centro del D-Pad para pausar/reproducir</string> + <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> @@ -119,7 +122,7 @@ <string name="discovered_servers">Servidores detectados</string> <string name="download_and_update"><![CDATA[Descargar y actualizar]]></string> <string name="downloading">Descargando…</string> - <string name="enter_server_url">Introduce la IP o URL del servidor, incluido el puerto</string> + <string name="enter_server_url">Introduce la IP o URL del servidor</string> <string name="error_loading_collection">Error cargando la colección %1$s</string> <string name="forced_track">Forzado</string> <string name="go_to_series">Ir a la serie</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,74 +167,74 @@ <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> <item quantity="many">Clips</item> - <item quantity="other"/> + <item quantity="other">Clips</item> </plurals> <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> @@ -240,7 +243,7 @@ <string name="check_for_updates">Buscar actualizaciones</string> <string name="combine_continue_next_summary">Aplica solo a series</string> <string name="combine_continue_next"><![CDATA[Combinar “Continuar viendo” y “Siguiente”]]></string> - <string name="direct_play_ass">Reproducción directa de subtítulos ASS</string> + <string name="direct_play_ass">Usar libass para reproducir subtítulos ASS</string> <string name="direct_play_pgs">Reproducción directa de subtítulos PGS</string> <string name="downmix_stereo">Convertir siempre a estéreo</string> <string name="ffmpeg_extension_pref">Usar módulo decodificador FFmpeg</string> @@ -264,8 +267,8 @@ <string name="skip_back_preference">Retroceder</string> <string name="skip_commercials_behavior">Comportamiento al saltar anuncios</string> <string name="skip_forward_preference">Avanzar</string> - <string name="skip_intro_behavior">Comportamiento al saltar la intro</string> - <string name="skip_outro_behavior">Comportamiento al saltar el outro</string> + <string name="skip_intro_behavior">Comportamiento al saltar la cabecera</string> + <string name="skip_outro_behavior">Comportamiento al saltar créditos finales</string> <string name="skip_previews_behavior">Comportamiento al saltar avances</string> <string name="skip_recap_behavior">Comportamiento al saltar resumen</string> <string name="update_available">Actualización disponible</string> @@ -291,10 +294,10 @@ <string name="skip_segment_commercial">Saltar anuncios</string> <string name="skip_segment_preview">Saltar avance</string> <string name="skip_segment_recap">Saltar resumen</string> - <string name="skip_segment_outro">Saltar outro</string> - <string name="skip_segment_intro">Saltar intro</string> + <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> @@ -401,11 +404,12 @@ <string name="clear_track_choices">Opciones de pista claras</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">Reproducción directa 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> @@ -422,4 +426,334 @@ <string name="request_4k">Solicitar en 4K</string> <string name="software_decoding_av1">Decodificación por software AV1</string> <string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string> + <plurals name="days"> + <item quantity="one">%s día</item> + <item quantity="many">%s días</item> + <item quantity="other">%s días</item> + </plurals> + <string name="quick_connect">Conexión Rápida</string> + <string name="quick_connect_summary">Autoriza a otro dispositivo para acceder a tu cuenta</string> + <string name="quick_connect_code">Introduce el código de Conexión Rápida</string> + <string name="quick_connect_code_error">El código debe tener 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado con éxito</string> + <string name="hdr_subtitle_style">Estilo de subtítulos HDR</string> + <string name="image_subtitle_opacity">Opacidad de los subtítulos</string> + <string name="brightness">Brillo</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturación</string> + <string name="hue">Tono de color</string> + <string name="red">Rojo</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desenfoque</string> + <string name="save_for_album">Guardar en el álbum</string> + <string name="play_slideshow">Reproducir presentación</string> + <string name="stop_slideshow">Detener presentación</string> + <string name="slideshow_at_beginning">Presentación inicial</string> + <string name="no_more_images">No hay más imágenes</string> + <string name="rotate_left">Girar a la izquierda</string> + <string name="rotate_right">Girar a la derecha</string> + <string name="zoom_in">Acercar</string> + <string name="zoom_out">Alejar</string> + <string name="slideshow_duration">Duración de la presentación</string> + <string name="play_videos_during_slideshow">Reproducir vídeos durante la presentación</string> + <string name="send_media_info_log_to_server">Enviar registro de medios al servidor</string> + <string name="no_limit">Sin límite</string> + <string name="max_days_next_up">Límite de días en \"Siguiente\"</string> + <string name="add_row">Añadir fila</string> + <string name="genres_in">Géneros de %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas las filas</string> + <string name="customize_home">Personalizar página de inicio</string> + <string name="home_rows">Filas de inicio</string> + <string name="load_from_server">Cargar perfil de usuario desde el servidor</string> + <string name="save_to_server">Guardar perfil de usuario en el servidor</string> + <string name="load_from_web_client">Cargar del cliente web</string> + <string name="use_series">Usar imágenes de la serie</string> + <string name="add_row_for">Agregar fila para %1$s</string> + <string name="overwrite_server_settings">¿Sobrescribir ajustes en el servidor?</string> + <string name="overwrite_local_settings">¿Sobrescribir ajustes locales?</string> + <string name="for_episodes">Para episodios</string> + <string name="suggestions_for">Sugerencias para %1$s</string> + <string name="increase_all_cards_size">Aumentar tamaño de todas las tarjetas</string> + <string name="decrease_all_cards_size">Reducir tamaño de todas las tarjetas</string> + <string name="use_thumb_images">Usar miniaturas</string> + <string name="settings_saved">Ajustes guardados</string> + <string name="display_presets">Preajustes de visualización</string> + <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_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> + <string name="content_scale_fill_width">Ajustar al ancho</string> + <string name="content_scale_fill_height">Ajustar al alto</string> + <string name="display_preset_series_thumb">Miniatura de la serie</string> + <string name="display_preset_episode_thumbnails">Miniatura del episodio</string> + <string name="volume_lowest">Mínimo</string> + <string name="volume_low">Bajo</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Morado</string> + <string name="orange">Naranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Negro</string> + <string name="display_preset_default">Póster</string> + <string name="display_preset_compact">Póster compacto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Omitir automáticamente</string> + <string name="skip_ask">Preguntar para omitir</string> + <string name="ffmpeg_fallback">Usar FFmpeg solo si no existe un decodificador integrado</string> + <string name="ffmpeg_prefer">Preferir FFmpeg sobre los decodificadores integrados</string> + <string name="ffmpeg_never">No usar nunca decodificadores FFmpeg</string> + <string name="next_up_playback_end">Al finalizar la reproducción</string> + <string name="next_up_outro">Durante los créditos finales</string> + <string name="white">Blanco</string> + <string name="light_gray">Gris claro</string> + <string name="dark_gray">Gris oscuro</string> + <string name="yellow">Amarillo</string> + <string name="cyan">Cian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Ajustado al texto</string> + <string name="background_style_boxed">Enmarcado</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reproducir contenido HDR</string> + <string name="aspect_ratios_poster">Póster (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">Cuadrada (1:1)</string> + <string name="image_type_primary">Primaria</string> + <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[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> + <string name="seerr_local_user">Usuario local</string> + <string name="search_and_download_subtitles"><![CDATA[Buscar y descargar subtítulos]]></string> + <string name="no_subtitles_found">No se encontraron subtítulos externos</string> + <string name="series_continueing">Actualidad</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Comenzar después de</string> + <string name="animate">Animación</string> + <string name="max_age_rating">Clasificación por edad máxima</string> + <string name="for_all_ages">Para todas las edades</string> + <string name="up_to_age">Menores de %s</string> + <string name="screensaver">Salvapantallas</string> + <string name="screensaver_settings">Configuración de salvapantallas</string> + <string name="start_screensaver">Iniciar salvapantallas</string> + <string name="duration">Duración</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Qué tipos de elementos mostrar</string> + <string name="in_app_screensaver">Usar salvapantallas de la aplicación</string> + <string name="no_max">Sin límite</string> + <string name="actor">Intérprete</string> + <string name="composer">Compositor</string> + <string name="writer">Guionista</string> + <string name="guest_star">Estrella invitada</string> + <string name="producer">Productor</string> + <string name="conductor">Director musical</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arreglista</string> + <string name="engineer">Ingeniero de sonido</string> + <string name="mixer">Ingeniero de mezcla</string> + <string name="creator">Creador</string> + <string name="artist">Artista</string> + <string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string> + <string name="show_media_management">Mostrar opciones de gestión de medios</string> + <string name="search_for">Buscar %s</string> + <string name="select_all">Seleccionar todo</string> + <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">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> + <string name="artists">Artistas</string> + <string name="songs">Canciones</string> + <string name="go_to_artist">Ir al artista</string> + <string name="now_playing">Reproduciendo</string> + <string name="instant_mix">Mezcla instantánea</string> + <string name="go_to_album">Ir al álbum</string> + <string name="add_to_queue">Añadir a la cola</string> + <string name="album_artist">Artista del álbum</string> + <string name="album">Álbum</string> + <string name="popular_songs">Más escuchadas</string> + <string name="play_next">Reproducir a continuación</string> + <string name="music_videos">Videoclips</string> + <string name="lyrics">Letras</string> + <string name="hide_lyrics">Ocultar letras</string> + <string name="show_lyrics">Mostrar letras</string> + <string name="song_has_lyrics">Canción con letra</string> + <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">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> + <string name="remove_continue_watching">Eliminar de \"Continuar viendo\"</string> + <string name="remove_next_up">Eliminar de \"Siguiente\"</string> + <string name="view_removed_next_up">Ver series eliminadas de \"Siguiente\"</string> + <string name="studios_in">Estudios en %1$s</string> + <string name="ass_subtitle_mode_libass">Reproducción directa con libass</string> + <string name="ass_subtitle_mode_exoplayer">Reproducción directa con ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Incrustar/transcodificar en el servidor</string> + <string name="ass_subtitle_playback">Reproducción de subtítulos SSA/ASS</string> + <string name="last_played">Última reproducción</string> + <string name="next_up_never">Nunca</string> + <string name="external_player">Reproductor externo</string> + <string name="system_default">Predeterminado del sistema</string> + <string name="show_more">Muestra %1$s más</string> + <string name="skip_behavior">Comportamiento al saltar</string> + <string name="skip_behavior_summary">Para cabecera, créditos finales, etc</string> + <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 482554a0..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> @@ -251,7 +253,7 @@ <string name="check_for_updates">Kontrolli uuendusi</string> <string name="combine_continue_next_summary">Kehtib vaid telesarjade puhul</string> <string name="settings">Seadistused</string> - <string name="direct_play_ass">Esita ASS-vormingus subtiitreid otse</string> + <string name="direct_play_ass">Kasuta ASS-vormingus subtiitrite jaoks libass teeki</string> <string name="direct_play_pgs">Esita PGS-vormingus subtiitreid otse</string> <string name="downmix_stereo">Alati vähenda helikanalite arvu stereoni</string> <string name="ffmpeg_extension_pref">Kasuta FFmpegi dekooderi moodulit</string> @@ -367,6 +369,7 @@ <string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</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> @@ -427,4 +430,333 @@ <string name="zoom_out">Suumi välja</string> <string name="slideshow_duration">Slaidiesitluse kestus</string> <string name="play_videos_during_slideshow">Slaidiesitluse ajal esita videoid</string> + <plurals name="days"> + <item quantity="one">%s päev</item> + <item quantity="other">%s päeva</item> + </plurals> + <string name="quick_connect">Kiirühendus</string> + <string name="quick_connect_summary">Luba muul seadmel logida sinu kasutajakontoga sisse</string> + <string name="quick_connect_code">Sisesta kiirühenduse kood</string> + <string name="quick_connect_code_error">Kood peab olema kuuenumbriline</string> + <string name="quick_connect_success">Seadme autentimine õnnestus</string> + <string name="send_media_info_log_to_server">Salvesta meediumi infologi serverisse</string> + <string name="no_limit">Piirangut pole</string> + <string name="max_days_next_up">Järgmisena esitamisel sisu päevade ülempiir</string> + <string name="add_row">Lisa rida</string> + <string name="genres_in">Žanrid: %1$s</string> + <string name="recently_released_in">Hiljuti avaldatud: %1$s</string> + <string name="height">Kõrgus</string> + <string name="apply_all_rows">Rakenda kõikidele ridadele</string> + <string name="customize_home">Kohanda kodulehte</string> + <string name="home_rows">Kodulehe/avalehe read</string> + <string name="load_from_server">Laadi kasutajaprofiil serverist</string> + <string name="save_to_server">Salvesta kasutajaprofiil serverisse</string> + <string name="load_from_web_client">Laadi veebikliendist</string> + <string name="use_series">Kasuta sarja pilti</string> + <string name="add_row_for">Lisa rida: %1$s</string> + <string name="overwrite_server_settings">Kas kirjutad serveris leiduvad seadistused üle?</string> + <string name="overwrite_local_settings">Kas kirjutad kohalikud seadistused üle?</string> + <string name="for_episodes">Episoodide jaoks</string> + <string name="suggestions_for">Soovitused: %1$s</string> + <string name="increase_all_cards_size">Suurenda suurust kõikide kaartide jaoks</string> + <string name="decrease_all_cards_size">Vähenda suurust kõikide kaartide jaoks</string> + <string name="use_thumb_images">Kasuta pisipilte</string> + <string name="settings_saved">Seadistused on salvesatatud</string> + <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_title">Märgi %s lemmikuks</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minutit</item> + </plurals> + <string name="purple">Purpurpunane</string> + <string name="orange">Oranž</string> + <string name="bold_blue">Tugev sinine</string> + <string name="black">Must</string> + <string name="skip_ignore">Eira</string> + <string name="skip_automatically">Jäta automaatselt vahele</string> + <string name="skip_ask">Vahelejätmiseks küsi</string> + <string name="ffmpeg_fallback">Kui ühtegi kaasnevat dekoodrit pole, siis kasuta vaid FFmpeg-i</string> + <string name="ffmpeg_prefer">Eelista kaasnevatele dekoodritele FFmpeg-i</string> + <string name="ffmpeg_never">Ära iialgi kasuta dekoderimiseks FFmpeg-i</string> + <string name="next_up_playback_end">Taasesituse lõpus</string> + <string name="white">Valge</string> + <string name="light_gray">Helehall</string> + <string name="dark_gray">Tumehall</string> + <string name="yellow">Kollane</string> + <string name="cyan">Rohekassinine</string> + <string name="magenta">Fuksiapunane</string> + <string name="subtitle_edge_outline">Äärisjoon</string> + <string name="subtitle_edge_shadow">Vari</string> + <string name="background_style_wrap">Reamurdmine</string> + <string name="prefer_mpv">Eelista MPV-d</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR-i taasesituseks kasuta ExoPlayerit</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">Ruut (1:1)</string> + <string name="image_type_thumb">Sõrmejälg</string> + <string name="image_type_primary">Esmane</string> + <string name="backdrop_style_dynamic">Pilt dünaamiliste värvidega</string> + <string name="backdrop_style_image">Ainult pilt</string> + <string name="enter_url_api_key"><![CDATA[Sisesta võrguaadress ja API võti]]></string> + <string name="api_key">API võti</string> + <string name="seerr_login">Logi sisse Seerri serverisse</string> + <string name="seerr_jellyfin_user">Jellyfini kasutaja</string> + <string name="seerr_local_user">Kohalik kasutaja</string> + <string name="search_and_download_subtitles"><![CDATA[Subtiitrite otsing ja allalaadimine]]></string> + <string name="no_subtitles_found">Kaugseadmest ei leidnunud subtiitreid</string> + <string name="in_app_screensaver">Kasuta rakendusesisest ekraanisäästjat</string> + <string name="delete_item">Kas sa oled kindel, et soovid selle objekti kustutada?</string> + <string name="show_media_management">Näida meediumi haldamise valikuid</string> + <string name="actor">Näitleja</string> + <string name="composer">Helilooja</string> + <string name="writer">Kirjanik</string> + <string name="guest_star">Külalisesineja</string> + <string name="producer">Tootja</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Laulusõnade autor</string> + <string name="arranger">Seade autor</string> + <string name="engineer">Heliinsener</string> + <string name="mixer">Helimiksija</string> + <string name="creator">Looja</string> + <string name="artist">Kunstnik</string> + <string name="search_for">Otsi: %s</string> + <string name="select_all">Vali kõik</string> + <string name="background_style_boxed">Ruudustatud</string> + <string name="screensaver_settings">Ekraanisäästja seadistused</string> + <string name="start_screensaver">Käivita ekraanisäästja</string> + <string name="duration">Kestus</string> + <string name="photos">Fotod</string> + <string name="include_types">Kaasa tüübid</string> + <string name="content_scale_fit">Sobita</string> + <string name="content_scale_crop">Kadreeri</string> + <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">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> + <string name="volume_low">Vaikne</string> + <string name="volume_medium">Keskmine</string> + <string name="volume_high">Vali</string> + <string name="volume_full">Täisvaljus</string> + <string name="start_after">Käivita peale</string> + <string name="animate">Animeeri</string> + <string name="max_age_rating">Ülemine vanusepiirang</string> + <string name="no_max">Puudub</string> + <string name="for_all_ages">Sobilik kõikidele vanustele</string> + <string name="up_to_age">Kuni vanuseni %s</string> + <string name="screensaver">Ekraanisäästja</string> + <string name="next_up_outro">Lõputiitrite ja lõpuklipi ajal</string> + <string name="series_continueing">Jätkuv sari</string> + <string name="include_types_summary">Mis tüüpi objekte peaks piltide kontekstis näitama</string> + <string name="albums">Albumid</string> + <string name="artists">Esitajad</string> + <string name="songs">Lood</string> + <string name="go_to_artist">Mine esitaja juurde</string> + <string name="now_playing">Hetkel esitamisel</string> + <string name="instant_mix">Kiirmiks</string> + <string name="go_to_album">Mine albumi juurde</string> + <string name="add_to_queue">Lisa esitusjärjekorda</string> + <string name="album_artist">Albumi esitaja</string> + <string name="album">Album</string> + <string name="popular_songs">Populaarsed lood</string> + <string name="play_next">Esita järgmisena</string> + <string name="music_videos">Muusikavideod</string> + <string name="lyrics">Laulusõnad</string> + <string name="hide_lyrics">Peida laulusõnad</string> + <string name="show_lyrics">Näita laulusõnu</string> + <string name="song_has_lyrics">Laulusõnad on olemas</string> + <string name="remove_from_queue">Eemalda esitusjärjekorrast</string> + <string name="show_album_cover">Näita albumi kaanepilti</string> + <string name="show_visualizer">Näita visualiseerijat</string> + <string name="show_backdrop">Näita tausta</string> + <string name="play_as_type">Kas esitad kui?</string> + <string name="visualizer_rationale">Hetkel esitamisel heliriba visualiseerimine toimib, kui rakendusel on õigus heli salvestada.</string> + <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">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> + <string name="view_more">Vaata veel</string> + <string name="discover_tv">Leia telesaateid</string> + <string name="discover_movies">Leia filme</string> + <string name="studios_in">Stuudiod: %1$s</string> + <string name="last_played">Viimati esitatud</string> + <string name="ass_subtitle_mode_libass">Otseesitus libass teegiga</string> + <string name="ass_subtitle_mode_exoplayer">Otseesitus sisseehitatud ExoPlayeri teegiga</string> + <string name="ass_subtitle_mode_transcode">Kodeeri serveris</string> + <string name="ass_subtitle_playback">SSA/ASS subtiitrite esitus</string> + <string name="next_up_never">Mitte kunagi</string> + <string name="external_player">Väline meediapleier</string> + <string name="system_default">Süsteemi vaikeväärtus</string> + <string name="cinema_mode">Kinovaade</string> + <string name="show_more">Näita veel: %1$s tk</string> + <string name="skip_behavior">Käitumine vahelejätmisel</string> + <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-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c131a379..85603d28 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,7 @@ <string name="community_rating">Note de la communauté</string> <string name="compose_error_message_title">Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur.</string> <string name="critic_rating">Note des critiques</string> - <string name="decimal_seconds">%.1f secondes</string> + <string name="decimal_seconds">%.2f secondes</string> <string name="died">Décédé(e)</string> <string name="disabled">Désactivé</string> <string name="downloading">Téléchargement en cours…</string> @@ -35,7 +35,7 @@ <string name="file_size">Taille</string> <string name="forced_track">Forcé</string> <string name="genres">Genres</string> - <string name="go_to_series">Aller à la série</string> + <string name="go_to_series">Voir la série</string> <string name="go_to">Aller à</string> <string name="hide">Masquer</string> <string name="home">Accueil</string> @@ -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> @@ -197,7 +200,7 @@ <string name="global_content_scale">Échelle de contenu par défaut</string> <string name="install_update">Installer la mise à jour</string> <string name="installed_version">Version installée</string> - <string name="max_homepage_items">Nombre maximal d\'éléments par ligne sur la page d\'accueil</string> + <string name="max_homepage_items">Éléments sur les lignes de la page d\'accueil</string> <string name="nav_drawer_pins_summary">Choisissez les éléments à afficher par défaut, les autres seront masqués</string> <string name="nav_drawer_pins">Personnaliser les éléments du menu de navigation</string> <string name="nav_drawer_switch_on_focus_summary_off">Cliquez pour changer de page</string> @@ -221,8 +224,8 @@ <string name="font_opacity">Opacité de la police</string> <string name="edge_style">Style des contours</string> <string name="edge_color">Couleur des contours</string> - <string name="background_opacity">Opacité de l’arrière-plan</string> - <string name="background_style">Style de l’arrière-plan</string> + <string name="background_opacity">Opacité de l\'arrière-plan</string> + <string name="background_style">Style de l\'arrière-plan</string> <string name="subtitle_style">Style des sous-titres</string> <string name="background_color">Couleur de fond</string> <string name="reset">Réinitialiser</string> @@ -234,10 +237,10 @@ <string name="exoplayer_options">Options ExoPlayer</string> <string name="skip_segment_unknown">Passer le segment</string> <string name="skip_segment_commercial">Passer les publicités</string> - <string name="skip_segment_preview">Passer l’aperçu</string> + <string name="skip_segment_preview">Passer l\'aperçu</string> <string name="skip_segment_recap">Passer le récapitulatif</string> <string name="skip_segment_outro">Passer le générique de fin</string> - <string name="skip_segment_intro">Passer l’intro</string> + <string name="skip_segment_intro">Passer l\'intro</string> <string name="played">Lu</string> <string name="filter">Filtre</string> <string name="year">Année</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> @@ -275,30 +278,30 @@ <string name="play_theme_music">Lire la musique de thème</string> <string name="skip_back_on_resume_preference">Revenir en arrière à la reprise de la lecture</string> <string name="skip_commercials_behavior">Comportement de saut des publicités</string> - <string name="skip_intro_behavior">Comportement de saut de l’intro</string> + <string name="skip_intro_behavior">Comportement de saut de l\'intro</string> <string name="skip_outro_behavior">Comportement de saut du générique de fin</string> <string name="skip_previews_behavior">Comportement de saut des aperçus</string> <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> - <string name="search_and_download"><![CDATA[Rechercher & Télécharger]]></string> + <string name="combine_continue_next"><![CDATA[Combiner Continuer à regarder & À suivre]]></string> + <string name="download_and_update"><![CDATA[Télécharger & Mettre à jour]]></string> + <string name="search_and_download"><![CDATA[Rechercher & Télécharger]]></string> <string name="tv_dvr_schedule">Calendrier du DVR</string> <string name="aired_episode_order">Trié par date de diffusion</string> <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">Featurettes</item> - <item quantity="many"/> - <item quantity="other"/> + <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> @@ -315,7 +318,7 @@ <string name="require_pin_code">Code PIN requis pour le profil</string> <string name="confirm_pin">Confirmer le code PIN</string> <string name="incorrect">Erroné</string> - <string name="pin_too_short">Le code PIN doit comporter au moins 4 éléments</string> + <string name="pin_too_short">Le code PIN doit comporter au moins 4 chiffres</string> <string name="will_remove_pin">Supprimera le code PIN</string> <string name="image_cache_size">Taille du cache disque de l\'image (Mo)</string> <string name="view_options">Options d\'affichage</string> @@ -323,7 +326,7 @@ <string name="spacing">Espacement</string> <string name="aspect_ratio">Format d\'image</string> <string name="show_details">Afficher les détails</string> - <string name="cast_and_crew">Distribution et équipe</string> + <string name="cast_and_crew"><![CDATA[Distribution et équipe]]></string> <string name="guest_stars">Invités spéciaux</string> <string name="edge_size">Taille des bordures</string> <string name="refresh_rate_switching">Changement de fréquence de rafraîchissement</string> @@ -382,6 +385,7 @@ <string name="no_trailers">Pas de bandes-annonces</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> @@ -423,7 +427,7 @@ <string name="software_decoding_av1">Décodage logiciel AV1</string> <string name="upgrade_mpv_toast">MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres.</string> <string name="hdr_subtitle_style">Style de sous-titres HDR</string> - <string name="image_subtitle_opacity">opacité du sous-titre de l\'image</string> + <string name="image_subtitle_opacity">Opacité du sous-titre de l\'image</string> <string name="brightness">Luminosité</string> <string name="contrast">Contraste</string> <string name="saturation">Saturation</string> @@ -443,4 +447,339 @@ <string name="zoom_out">Dézoomer</string> <string name="slideshow_duration">Durée du diaporama</string> <string name="play_videos_during_slideshow">Diffuser des vidéos pendant le diaporama</string> + <plurals name="days"> + <item quantity="one">%s jour</item> + <item quantity="many">%s jours</item> + <item quantity="other">%s jours</item> + </plurals> + <string name="quick_connect">Connexion rapide</string> + <string name="quick_connect_summary">Autoriser un autre appareil à se connecter à votre compte</string> + <string name="quick_connect_code">Entrez le code de connexion rapide</string> + <string name="quick_connect_code_error">Le code doit comporter 6 chiffres</string> + <string name="quick_connect_success">Appareil autorisé avec succès</string> + <string name="send_media_info_log_to_server">Envoyer le journal des informations multimédias au serveur</string> + <string name="no_limit">Aucune limite</string> + <string name="max_days_next_up">Nombre maximal de jours dans Prochainement</string> + <string name="add_row">Ajouter une ligne</string> + <string name="genres_in">Genres en %1$s</string> + <string name="recently_released_in">Récemment publié en %1$s</string> + <string name="height">Hauteur</string> + <string name="apply_all_rows">S\'applique à toutes les lignes</string> + <string name="customize_home">Personnaliser la page d\'accueil</string> + <string name="home_rows">Lignes d\'accueil</string> + <string name="load_from_server">Charger à partir du profil utilisateur du serveur</string> + <string name="save_to_server">Enregistrer dans le profil utilisateur du serveur</string> + <string name="load_from_web_client">Charger à partir du client Web</string> + <string name="use_series">Utiliser l\'image de la série</string> + <string name="add_row_for">Ajouter une ligne pour %1$s</string> + <string name="overwrite_server_settings">Écraser les paramètres sur le serveur ?</string> + <string name="overwrite_local_settings">Écraser les paramètres locaux ?</string> + <string name="for_episodes">Pour les épisodes</string> + <string name="suggestions_for">Suggestions pour %1$s</string> + <string name="increase_all_cards_size">Augmenter la taille pour toutes les cartes</string> + <string name="decrease_all_cards_size">Diminuer la taille pour toutes les cartes</string> + <string name="use_thumb_images">Utiliser des miniatures</string> + <string name="settings_saved">Paramètres enregistrés</string> + <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_title">Favori %s</string> + <plurals name="minutes"> + <item quantity="one">%s minute</item> + <item quantity="many">%s minutes</item> + <item quantity="other">%s minute</item> + </plurals> + <string name="start_after">Démarrer après</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Classification par âge maximale</string> + <string name="no_max">Pas de maximum</string> + <string name="for_all_ages">Pour tous les âges</string> + <string name="up_to_age">Jusqu\'à l\'âge de %s</string> + <string name="screensaver">Écran de veille</string> + <string name="screensaver_settings">Paramètres de l\'économiseur d\'écran</string> + <string name="start_screensaver">Lancer l\'économiseur d\'écran</string> + <string name="duration">Durée</string> + <string name="photos">Photos</string> + <string name="include_types">Inclure les types</string> + <string name="include_types_summary">Quels types d\'éléments afficher des images pour</string> + <string name="content_scale_fit">Ajustement</string> + <string name="content_scale_crop">Recadrer</string> + <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">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> + <string name="volume_low">Faible</string> + <string name="volume_medium">Moyen</string> + <string name="volume_high">Haut</string> + <string name="volume_full">Maximum</string> + <string name="purple">Violet</string> + <string name="orange">Orange</string> + <string name="bold_blue">Bleu vif</string> + <string name="black">Noir</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Passer automatiquement</string> + <string name="skip_ask">Demander à passer</string> + <string name="ffmpeg_fallback">N\'utiliser FFmpeg que si aucun décodeur intégré n\'existe</string> + <string name="ffmpeg_prefer">Préférer l\'utilisation de FFmpeg plutôt que les décodeurs intégrés</string> + <string name="ffmpeg_never">Ne jamais utiliser les décodeurs FFmpeg</string> + <string name="next_up_playback_end">À la fin de la lecture</string> + <string name="next_up_outro">Pendant le générique de fin/outro</string> + <string name="white">Blanc</string> + <string name="light_gray">Gris clair</string> + <string name="dark_gray">Gris foncé</string> + <string name="yellow">Jaune</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contour</string> + <string name="subtitle_edge_shadow">Ombre</string> + <string name="background_style_wrap">Envelopper</string> + <string name="background_style_boxed">Encadré</string> + <string name="prefer_mpv">Préférer MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Utiliser ExoPlayer pour la lecture HDR</string> + <string name="aspect_ratios_poster">Affiche (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">Carré (1:1)</string> + <string name="image_type_primary">Primaire</string> + <string name="image_type_thumb">Miniature</string> + <string name="backdrop_style_dynamic">Image avec couleur dynamique</string> + <string name="backdrop_style_image">Image uniquement</string> + <string name="enter_url_api_key"><![CDATA[Saisir l\'URL et la clé API]]></string> + <string name="api_key">Clé API</string> + <string name="seerr_login">Se connecter au serveur Seerr</string> + <string name="seerr_jellyfin_user">Utilisateur Jellyfin</string> + <string name="seerr_local_user">Utilisateur local</string> + <string name="search_and_download_subtitles"><![CDATA[Rechercher et télécharger des sous-titres]]></string> + <string name="no_subtitles_found">Aucun sous-titre distant n\'a été trouvé</string> + <string name="series_continueing">En cours</string> + <string name="in_app_screensaver">Utiliser l\'économiseur d\'écran intégré</string> + <string name="actor">Acteur</string> + <string name="composer">Compositeur</string> + <string name="writer">Scénariste</string> + <string name="guest_star">Invité spécial</string> + <string name="producer">Producteur</string> + <string name="conductor">Chef d\'orchestre</string> + <string name="lyricist">Parolier</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingénieur</string> + <string name="mixer">Mixeur</string> + <string name="creator">Créateur</string> + <string name="artist">Artiste</string> + <string name="delete_item">Êtes-vous sûr de vouloir supprimer cet élément ?</string> + <string name="show_media_management">Afficher les options de gestion des médias</string> + <string name="search_for">Recherche %s</string> + <string name="select_all">Tout sélectionner</string> + <string name="albums">Albums</string> + <string name="artists">Artistes</string> + <string name="songs">Chansons</string> + <string name="go_to_artist">Voir l\'artiste</string> + <string name="now_playing">Lecture en cours</string> + <string name="instant_mix">Mix instantané</string> + <string name="go_to_album">Voir l\'album</string> + <string name="add_to_queue">Ajouter à la file</string> + <string name="album_artist">Artiste de l\'album</string> + <string name="album">Album</string> + <string name="popular_songs">Titres populaires</string> + <string name="play_next">Lire ensuite</string> + <string name="music_videos">Clips</string> + <string name="lyrics">Paroles</string> + <string name="hide_lyrics">Masquer les paroles</string> + <string name="show_lyrics">Afficher les paroles</string> + <string name="song_has_lyrics">Contient des paroles</string> + <string name="remove_from_queue">Retirer de la file</string> + <string name="show_album_cover">Afficher la pochette</string> + <string name="show_visualizer">Afficher le visualiseur</string> + <string name="show_backdrop">Afficher l\'arrière-plan</string> + <string name="play_as_type">Qui écoute ?</string> + <string name="visualizer_rationale">L\'affichage du visualiseur nécessite l\'autorisation d\'enregistrer du contenu audio.</string> + <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">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> + <string name="remove_continue_watching">Supprimer de continuer à regarder</string> + <string name="remove_next_up">Supprimer la série de la liste suivante</string> + <string name="view_removed_next_up">Voir les séries supprimées de la liste suivante</string> + <string name="studios_in">Studios en %1$s</string> + <string name="next_up_never">Jamais</string> + <string name="last_played">Dernière lecture</string> + <string name="ass_subtitle_mode_libass">Lecture directe avec libass</string> + <string name="ass_subtitle_mode_exoplayer">Lecture directe avec ExoPlayer intégré</string> + <string name="ass_subtitle_mode_transcode">Incruster / Transcoder sur le serveur</string> + <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 2e02fb53..f078a5f7 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -18,14 +18,14 @@ <string name="compose_error_message_title">Hiba történt! Nyomja meg a gombot, hogy elküldje a naplófájlokat a szervernek.</string> <string name="confirm">Megerősít</string> <string name="critic_rating">Kritikusi értékelés</string> - <string name="decimal_seconds">%.1f másodperc</string> + <string name="decimal_seconds">%.2f másodperc</string> <string name="default_track">Alapértelmezett</string> <string name="delete">Törlés</string> <string name="directed_by">Rendezte: %1$s</string> <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 03fd5a93..623ef596 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -10,7 +10,7 @@ <string name="bitrate">Bitrate</string> <string name="born">Lahir</string> <string name="cancel_recording">Batalkan Perekaman</string> - <string name="cancel_series_recording">Batalkan Perekaman Serial</string> + <string name="cancel_series_recording">Batalkan Perekaman Seri</string> <string name="cancel">Batal</string> <string name="chapters">Chapter</string> <string name="choose_stream">Pilih %1$s</string> @@ -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> @@ -35,13 +35,13 @@ <string name="episodes">Episode</string> <string name="error_loading_collection">Gagal memuat koleksi %1$s</string> <string name="discovered_servers">Server Ditemukan</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update"><![CDATA[Unduh & Perbarui]]></string> <string name="external_track">Eksternal</string> <string name="favorites">Favorit</string> <string name="disabled">Nonaktif</string> <string name="enabled">Aktif</string> <string name="genres">Genre</string> - <string name="go_to_series">Masuk ke serial</string> + <string name="go_to_series">Buka seri</string> <string name="go_to">Masuk ke</string> <string name="forced_track">Paksa</string> <string name="hide_controller_timeout">Sembunyikan kontrol pemutaran</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> @@ -91,12 +91,12 @@ <string name="recently_released">Baru Dirilis</string> <string name="recommended">Rekomendasi</string> <string name="record_program">Rekam Program</string> - <string name="record_series">Rekam Serial</string> + <string name="record_series">Rekam Seri</string> <string name="remove_favorite">Hapus dari Favorit</string> <string name="restart">Mulai ulang</string> <string name="resume">Lanjutkan</string> <string name="save">Simpan</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Pencarian & Unduhan]]></string> <string name="search">Pencarian</string> <string name="searching">Mencari…</string> <string name="select_server">Pilih Server</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> @@ -203,9 +204,9 @@ <string name="auto_play_next_delay">Jeda sebelum putar selanjutnya</string> <string name="auto_play_next">Putar otomatis selanjutnya</string> <string name="check_for_updates">Periksa Pembaruan</string> - <string name="combine_continue_next_summary">Hanya berlaku untuk Serial TV</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Putar langsung subtitel ASS</string> + <string name="combine_continue_next_summary">Hanya berlaku untuk Seri TV</string> + <string name="combine_continue_next"><![CDATA[Gabungkan Lanjut Menonton & Berikutnya]]></string> + <string name="direct_play_ass">Gunakan libass untuk subtitel ASS</string> <string name="direct_play_pgs">Putar langsung subtitel PGS</string> <string name="downmix_stereo">Selalu downmix ke stereo</string> <string name="ffmpeg_extension_pref">Gunakan dekoder FFmpeg</string> @@ -242,8 +243,8 @@ <string name="font_size">Ukuran font</string> <string name="font_color">Warna font</string> <string name="font_opacity">Transparansi font</string> - <string name="edge_style">Jenis garis tepi</string> - <string name="edge_color">Warna garis tepi</string> + <string name="edge_style">Jenis pinggiran</string> + <string name="edge_color">Warna pinggiran</string> <string name="background_opacity">Transparansi latar belakang</string> <string name="background_style">Gaya latar belakang</string> <string name="subtitle_style">Gaya subtitel</string> @@ -287,7 +288,7 @@ <string name="aspect_ratio">Rasio Aspek</string> <string name="show_details">Tampilkan detail</string> <string name="image_type">Jenis gambar</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew"><![CDATA[Pemeran & Kru]]></string> <string name="guest_stars">Pemeran Tamu</string> <string name="anamorphic">Anamorfik</string> <string name="interlaced">Interlace</string> @@ -318,7 +319,7 @@ <string name="require_pin_code">Membutuhkan PIN untuk profil</string> <string name="will_remove_pin">PIN akan dihapus</string> <string name="view_options">Opsi tampilan</string> - <string name="edge_size">Ukuran garis tepi</string> + <string name="edge_size">Ukuran pinggiran</string> <string name="automatic">Otomatis</string> <string name="username_or_password">Gunakan nama pengguna/kata sandi</string> <string name="subtitle_margin">Batas tepi</string> @@ -369,6 +370,7 @@ <string name="clear_track_choices">Hapus pilihan trek</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> @@ -412,4 +414,300 @@ <string name="slideshow_duration">Durasi tayangan slide</string> <string name="play_videos_during_slideshow">Putar video selama tayangan slide</string> <string name="send_media_info_log_to_server">Kirim log info media ke server</string> + <plurals name="days"> + <item quantity="other">%s hari</item> + </plurals> + <string name="quick_connect">Koneksi Cepat</string> + <string name="quick_connect_summary">Izinkan perangkat lain untuk masuk ke akun kamu</string> + <string name="quick_connect_code">Masukkan kode Koneksi Cepat</string> + <string name="quick_connect_code_error">Kode harus terdiri dari 6 digit</string> + <string name="quick_connect_success">Perangkat berhasil diizinkan</string> + <string name="no_limit">Tanpa batasan</string> + <string name="max_days_next_up">Batas hari untuk Berikutnya</string> + <string name="add_row">Tambah baris</string> + <string name="genres_in">Genre di %1$s</string> + <string name="recently_released_in">Baru saja dirilis di %1$s</string> + <string name="height">Tinggi</string> + <string name="apply_all_rows">Terapkan ke semua baris</string> + <string name="customize_home">Kustomisasi beranda</string> + <string name="home_rows">Baris beranda</string> + <string name="load_from_server">Muat dari profil pengguna server</string> + <string name="save_to_server">Simpan ke profil pengguna server</string> + <string name="load_from_web_client">Muat dari klien web</string> + <string name="use_series">Gunakan gambar seri</string> + <string name="add_row_for">Tambah baris untuk %1$s</string> + <string name="overwrite_server_settings">Timpa pengaturan di server?</string> + <string name="overwrite_local_settings">Timpa pengaturan lokal?</string> + <string name="for_episodes">Untuk episode</string> + <string name="suggestions_for">Rekomendasi untuk %1$s</string> + <string name="increase_all_cards_size">Perbesar ukuran semua kartu</string> + <string name="decrease_all_cards_size">Perkecil ukuran semua kartu</string> + <string name="use_thumb_images">Gunakan gambar thumbnail</string> + <string name="settings_saved">Pengaturan disimpan</string> + <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_title">Favoritkan %s</string> + <string name="artist">Artis</string> + <plurals name="minutes"> + <item quantity="other">%s menit</item> + </plurals> + <string name="start_after">Mulai setelah</string> + <string name="animate">Animasi</string> + <string name="max_age_rating">Rating usia maks</string> + <string name="no_max">Tidak ada maks</string> + <string name="for_all_ages">Untuk semua umur</string> + <string name="up_to_age">Maks. usia %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Pengaturan screensaver</string> + <string name="start_screensaver">Mulai screensaver</string> + <string name="duration">Durasi</string> + <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">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> + <string name="volume_low">Rendah</string> + <string name="volume_medium">Sedang</string> + <string name="volume_high">Tinggi</string> + <string name="volume_full">Penuh</string> + <string name="purple">Ungu</string> + <string name="orange">Oranye</string> + <string name="bold_blue">Biru Tegas</string> + <string name="black">Hitam</string> + <string name="skip_ignore">Abaikan</string> + <string name="skip_automatically">Lewati otomatis</string> + <string name="skip_ask">Tanya sebelum melewati</string> + <string name="ffmpeg_fallback">Gunakan FFmpeg hanya jika dekoder bawaan tidak ada</string> + <string name="ffmpeg_prefer">Utamakan FFmpeg daripada dekoder bawaan</string> + <string name="ffmpeg_never">Jangan pernah gunakan dekoder FFmpeg</string> + <string name="next_up_playback_end">Di akhir pemutaran</string> + <string name="next_up_outro">Selama kredit akhir/outro</string> + <string name="white">Putih</string> + <string name="light_gray">Abu-abu muda</string> + <string name="dark_gray">Abu-abu tua</string> + <string name="yellow">Kuning</string> + <string name="cyan">Sian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Garis luar</string> + <string name="prefer_mpv">Utamakan MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Gunakan ExoPlayer untuk pemutaran HDR</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">Persegi (1:1)</string> + <string name="image_type_primary">Utama</string> + <string name="image_type_thumb">Thumbnail</string> + <string name="backdrop_style_dynamic">Gambar dengan warna dinamis</string> + <string name="backdrop_style_image">Hanya gambar</string> + <string name="enter_url_api_key"><![CDATA[Masukkan URL & Kunci API]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Masuk ke server Seerr</string> + <string name="seerr_jellyfin_user">Pengguna Jellyfin</string> + <string name="seerr_local_user">Pengguna lokal</string> + <string name="search_and_download_subtitles"><![CDATA[Cari & unduh subtitel]]></string> + <string name="no_subtitles_found">Subtitel remote tidak ditemukan</string> + <string name="series_continueing">Tayang</string> + <string name="in_app_screensaver">Gunakan screensaver dalam aplikasi</string> + <string name="actor">Aktor</string> + <string name="composer">Komposer</string> + <string name="writer">Penulis</string> + <string name="guest_star">Bintang tamu</string> + <string name="producer">Produser</string> + <string name="conductor">Konduktor</string> + <string name="lyricist">Penulis lirik</string> + <string name="arranger">Aransemen</string> + <string name="engineer">Teknisi</string> + <string name="mixer">Mixer</string> + <string name="creator">Kreator</string> + <string name="select_all">Pilih semua</string> + <string name="search_for">Cari %s</string> + <string name="show_media_management">Tampilkan opsi kelola media</string> + <string name="delete_item">Apakah kamu yakin ingin menghapus item ini?</string> + <string name="subtitle_edge_shadow">Bayangan</string> + <string name="background_style_wrap">Pembungkus</string> + <string name="background_style_boxed">Kotak</string> + <string name="content_scale_fit">Sesuaikan</string> + <string name="content_scale_crop">Pangkas</string> + <string name="content_scale_fill">Layar penuh</string> + <string name="content_scale_fill_width">Lebar penuh</string> + <string name="content_scale_fill_height">Tinggi penuh</string> + <string name="prefer_logos">Utamakan logo untuk judul</string> + <string name="albums">Album</string> + <string name="artists">Artis</string> + <string name="songs">Lagu</string> + <string name="now_playing">Sedang diputar</string> + <string name="instant_mix">Campuran instan</string> + <string name="add_to_queue">Tambahkan ke antrean</string> + <string name="album_artist">Artis album</string> + <string name="album">Album</string> + <string name="popular_songs">Lagu populer</string> + <string name="play_next">Putar berikutnya</string> + <string name="music_videos">Video musik</string> + <string name="lyrics">Lirik</string> + <string name="hide_lyrics">Sembunyikan lirik</string> + <string name="show_lyrics">Tampilkan lirik</string> + <string name="song_has_lyrics">Tersedia lirik</string> + <string name="remove_from_queue">Hapus dari antrean</string> + <string name="show_album_cover">Tampilkan sampul album</string> + <string name="show_visualizer">Tampilkan visualisator</string> + <string name="show_backdrop">Tampilkan backdrop</string> + <string name="play_as_type">Putar sebagai?</string> + <string name="visualizer_rationale">Memvisualisasikan audio yang sedang diputar memerlukan izin untuk merekam audio.</string> + <string name="continue_string">Lanjutkan</string> + <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">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> + <string name="view_more">Lihat semua</string> + <string name="discover_tv">Jelajahi acara TV</string> + <string name="discover_movies">Jelajahi film</string> + <string name="studios_in">Studio dari %1$s</string> + <string name="next_up_never">Tidak pernah</string> + <string name="external_player">Pemutar eksternal</string> + <string name="last_played">Terakhir diputar</string> + <string name="system_default">Default sistem</string> + <string name="ass_subtitle_mode_libass">Putar langsung dengan libass</string> + <string name="ass_subtitle_mode_exoplayer">Putar langsung dengan bawaan ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Burn-in/transkode di server</string> + <string name="ass_subtitle_playback">Pemutaran subtitle SSA/ASS</string> + <string name="cinema_mode">Mode bioskop</string> + <string name="show_more">Tampilkan %1$s lagi</string> + <string name="transcoding">Transkode</string> + <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 e5aa51b4..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> @@ -59,7 +59,7 @@ <string name="restart">Riavvia</string> <string name="resume">Riprendi</string> <string name="save">Salva</string> - <string name="search_and_download"><![CDATA[Cerca & Scarica]]></string> + <string name="search_and_download"><![CDATA[Cerca & Scarica]]></string> <string name="search">Cerca</string> <string name="select_server">Seleziona Server</string> <string name="select_user">Seleziona Utente</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> @@ -233,7 +236,7 @@ <string name="app_theme">Tema Applicazione</string> <string name="auto_check_for_updates">Cerca automaticamente aggiornamenti</string> <string name="auto_play_next">Riproduci automaticamente il prossimo episodio</string> - <string name="direct_play_ass">Riproduzione diretta sottotitoli ASS</string> + <string name="direct_play_ass">Usa libass per i sottotitoli ASS</string> <string name="direct_play_pgs">Riproduzione diretta sottotitoli PGS</string> <string name="ffmpeg_extension_pref">Usa il modulo di decodifica FFmpeg</string> <string name="play_theme_music">Riproduci musica tema</string> @@ -254,16 +257,16 @@ </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="combine_continue_next"><![CDATA[Combina Continua a Guardare & Prossimo Episodio]]></string> <string name="downmix_stereo">Converti sempre in stereo</string> <string name="max_homepage_items">Numero massimo di elementi per riga nella home</string> <string name="global_content_scale">Scala contenuti predefinita</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> @@ -383,6 +386,7 @@ <string name="only_forced_subtitles">Solo sottotitoli forzati</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> @@ -441,7 +445,338 @@ <string name="rotate_right">Ruota a destra</string> <string name="zoom_in">Ingrandisci</string> <string name="zoom_out">Rimpicciolisci</string> - <string name="slideshow_duration">Durata slideshow</string> - <string name="play_videos_during_slideshow">Riproduci video durante lo slideshow</string> + <string name="slideshow_duration">Durata dello slideshow</string> + <string name="play_videos_during_slideshow">Riproduci i video durante lo slideshow</string> <string name="send_media_info_log_to_server">Invia log informazioni media al server</string> + <plurals name="days"> + <item quantity="one">%s giorno</item> + <item quantity="many">%s giorni</item> + <item quantity="other">%s giorni</item> + </plurals> + <string name="no_limit">Nessun limite</string> + <string name="max_days_next_up">Giorni massimi in Prossimo</string> + <string name="quick_connect">Connessione rapida</string> + <string name="quick_connect_summary">Autorizza un altro dispositivo per accedere al tuo account</string> + <string name="quick_connect_code">Inserisci il codice di Connessione rapida</string> + <string name="quick_connect_code_error">Il codice deve essere di 6 cifre</string> + <string name="quick_connect_success">Dispositivo autorizzato con successo</string> + <string name="add_row">Aggiungi riga</string> + <string name="genres_in">Generi in %1$s</string> + <string name="recently_released_in">Recentemente rilasciato in %1$s</string> + <string name="height">Altezza</string> + <string name="apply_all_rows">Applica a tutte le righe</string> + <string name="load_from_server">Carica dal profilo utente del server</string> + <string name="add_row_for">Aggiungi riga per %1$s</string> + <string name="overwrite_server_settings">Sovrascrivere le impostazioni sul server?</string> + <string name="overwrite_local_settings">Sovrascrivere le impostazioni locali?</string> + <string name="suggestions_for">Suggerimenti per %1$s</string> + <string name="increase_all_cards_size">Aumenta dimensione di tutte le card</string> + <string name="decrease_all_cards_size">Riduci dimensione di tutte le card</string> + <string name="use_thumb_images">Usa immagini thumbnail</string> + <string name="settings_saved">Impostazioni salvate</string> + <string name="display_presets">Mostra preset</string> + <string name="display_presets_description">Preset integrati per stilizzare rapidamente tutte le righe</string> + <string name="home_rows">Righe home</string> + <string name="for_episodes">Per gli episodi</string> + <string name="customize_home">Personalizza home page</string> + <string name="save_to_server">Salva nel profilo utente del server</string> + <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_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">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> + <string name="volume_low">Basso</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Massimo</string> + <string name="purple">Viola</string> + <string name="orange">Arancione</string> + <string name="bold_blue">Blu intenso</string> + <string name="black">Nero</string> + <string name="skip_ignore">Ignora</string> + <string name="skip_automatically">Salta automaticamente</string> + <string name="skip_ask">Chiedi se saltare</string> + <string name="ffmpeg_fallback">Usa FFmpeg solo se non esiste un decodificatore integrato</string> + <string name="ffmpeg_prefer">Preferisci FFmpeg ai decoder integrati</string> + <string name="ffmpeg_never">Non usare mai i decoder FFmpeg</string> + <string name="next_up_playback_end">Alla fine della riproduzione</string> + <string name="next_up_outro">Durante i titoli di coda</string> + <string name="white">Bianco</string> + <string name="light_gray">Grigio chiaro</string> + <string name="dark_gray">Grigio scuro</string> + <string name="yellow">Giallo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <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 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> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrato (1:1)</string> + <string name="image_type_primary">Principale</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Immagine con colore dinamico</string> + <string name="backdrop_style_image">Solo immagine</string> + <string name="enter_url_api_key"><![CDATA[Inserisci URL e chiave API]]></string> + <string name="api_key">Chiave API</string> + <string name="seerr_login">Accedi al server Seerr</string> + <string name="seerr_jellyfin_user">Utente Jellyfin</string> + <string name="seerr_local_user">Utente locale</string> + <string name="search_and_download_subtitles"><![CDATA[Cerca e scarica sottotitoli]]></string> + <string name="no_subtitles_found">Nessun sottotitolo remoto trovato</string> + <string name="series_continueing">In corso</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minuti</item> + <item quantity="other">%s minuti</item> + </plurals> + <string name="start_after">Inizia dopo</string> + <string name="animate">Anima</string> + <string name="max_age_rating">Età massima</string> + <string name="for_all_ages">Per tutte le età</string> + <string name="up_to_age">Fino a %s anni</string> + <string name="screensaver">Salvaschermo</string> + <string name="screensaver_settings">Impostazioni salvaschermo</string> + <string name="start_screensaver">Avvia salvaschermo</string> + <string name="duration">Durata</string> + <string name="photos">Foto</string> + <string name="include_types">Includi tipi</string> + <string name="include_types_summary">Quali tipi di elementi mostrare nelle immagini</string> + <string name="no_max">Nessun massimo</string> + <string name="in_app_screensaver">Usa salvaschermo dell\'app</string> + <string name="composer">Compositore</string> + <string name="writer">Sceneggiatore</string> + <string name="actor">Attore</string> + <string name="producer">Produttore</string> + <string name="guest_star">Ospite speciale</string> + <string name="conductor">Direttore d’orchestra</string> + <string name="lyricist">Paroliere</string> + <string name="arranger">Arrangiatore</string> + <string name="engineer">Ingegnere del suono</string> + <string name="mixer">Tecnico di mixaggio</string> + <string name="artist">Artista</string> + <string name="creator">Creatore</string> + <string name="search_for">Cerca %s</string> + <string name="delete_item">Sei sicuro di voler cancellare questo elemento?</string> + <string name="show_media_management">Mostra opzioni di gestione dei media</string> + <string name="select_all">Seleziona tutti</string> + <string name="albums">Album</string> + <string name="artists">Artisti</string> + <string name="songs">Canzoni</string> + <string name="go_to_artist">Vai all\'artista</string> + <string name="now_playing">In riproduzione</string> + <string name="instant_mix">Mix istantaneo</string> + <string name="go_to_album">Vai all\'album</string> + <string name="add_to_queue">Aggiungi alla coda</string> + <string name="album_artist">Album artista</string> + <string name="album">Album</string> + <string name="popular_songs">Canzoni popolari</string> + <string name="play_next">Riproduci successivo</string> + <string name="music_videos">Video musicali</string> + <string name="lyrics">Testi</string> + <string name="hide_lyrics">Nascondi testi</string> + <string name="show_lyrics">Mostra testi</string> + <string name="song_has_lyrics">La canzone ha i testi</string> + <string name="remove_from_queue">Rimuovi dalla coda</string> + <string name="show_album_cover">Mostra copertina album</string> + <string name="show_visualizer">Mostra visualizzatore</string> + <string name="show_backdrop">Mostra sfondo</string> + <string name="play_as_type">Riproduci come?</string> + <string name="visualizer_rationale">Per visualizzare l’audio in riproduzione è necessario il permesso di registrare l’audio.</string> + <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">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> + <string name="view_more">Mostra altro</string> + <string name="discover_tv">Scopri serie TV</string> + <string name="discover_movies">Scopri film</string> + <string name="studios_in">Studi in %1$s</string> + <string name="ass_subtitle_mode_libass">Riproduzione diretta con libass</string> + <string name="ass_subtitle_mode_exoplayer">Riproduzione diretta con ExoPlayer integrato</string> + <string name="ass_subtitle_mode_transcode">Masterizza/trascodifica sul server</string> + <string name="ass_subtitle_playback">Riproduzione sottotitoli SSA/ASS</string> + <string name="next_up_never">Mai</string> + <string name="external_player">Player esterno</string> + <string name="system_default">Predefinito sistema</string> + <string name="last_played">Ultima riproduzione</string> + <string name="cinema_mode">Modalità cinema</string> + <string name="show_more">Mostra %1$s altri</string> + <string name="skip_behavior">Comportamento di salto</string> + <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 51d5978a..d05db022 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -11,23 +11,749 @@ <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> <string name="commercial">מסחרי</string> <string name="community_rating">דירוג קהילתי</string> <string name="compose_error_message_title">אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך.</string> - <string name="confirm">לאשר</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="directed_by">בבימוי של %1$s</string> <string name="director">במאי</string> <string name="disabled">מושבת</string> <string name="born">נולד</string> <string name="choose_stream">בחר %1$s</string> + <string name="select_server">בחר שרת</string> + <string name="discovered_servers">שרתים שנמצאו</string> + <string name="searching">מחפש…</string> + <string name="enter_server_address">הזן כתובת שרת</string> + <string name="select_user">בחר משתמש</string> + <string name="switch_servers">החלף שרתים</string> + <string name="search">חיפוש</string> + <string name="home">דף הבית</string> + <string name="favorites">מועדפים</string> + <plurals name="movies"> + <item quantity="one">סרט</item> + <item quantity="two">סרטים</item> + <item quantity="other">סרטים</item> + </plurals> + <plurals name="tv_shows"> + <item quantity="one">סדרה</item> + <item quantity="two">סדרות</item> + <item quantity="other">סדרות</item> + </plurals> + <string name="tv_seasons">עונות</string> + <string name="tv_season">עונה</string> + <plurals name="trailers"> + <item quantity="one">קדימון</item> + <item quantity="two">קדימונים</item> + <item quantity="other">קדימונים</item> + </plurals> + <string name="subtitle">כתובית</string> + <string name="subtitles">כתוביות</string> + <string name="ui_interface">ממשק</string> + <string name="sign_in_auto">התחבר אוטומטית</string> + <string name="remember_selected_tab">זכור כרטיסיות שנבחרו</string> + <string name="enabled">מופעל</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="white">לבן</string> + <string name="login">התחבר</string> + <string name="username">שם משתמש</string> + <string name="password">סיסמה</string> + <string name="downloading">מוריד…</string> + <string name="ends_at">מסתיים ב־%1$s</string> + <string name="enter_server_url">הזן כתובת IP או URL</string> + <string name="episodes">פרקים</string> + <string name="external_track">חיצוני</string> + <string name="file_size">גודל</string> + <string name="genres">ז\'אנרים</string> + <string name="go_to_series">עבור לסדרה</string> + <string name="go_to">עבור אל</string> + <string name="hide">הסתר</string> + <string name="error_loading_collection">שגיאה בטעינת האוסף %1$s</string> + <string name="intro">פתיח</string> + <string name="library">תכנים</string> + <string name="more">עוד</string> + <string name="name">שם</string> + <string name="next_up">הבא בתור</string> + <string name="no_data">אין נתונים</string> + <string name="no_results">אין תוצאות</string> + <string name="no_servers_found">לא נמצאו שרתים</string> + <string name="outro">סיום</string> + <string name="none">ללא</string> + <plurals name="people"> + <item quantity="one">איש</item> + <item quantity="two">אנשים</item> + <item quantity="other">אנשים</item> + </plurals> + <string name="play_from_here">נגן מכאן</string> + <string name="play">נגן</string> + <string name="playback_speed">מהירות ניגון</string> + <string name="playback">ניגון</string> + <string name="playlist">רשימת־השמעה</string> + <string name="playlists">רשימות־השמעה</string> + <plurals name="downloads"> + <item quantity="one">הורדה %s</item> + <item quantity="two">%s הורדות</item> + <item quantity="other">%s הורדות</item> + </plurals> + <plurals name="hours"> + <item quantity="one">שעה %d</item> + <item quantity="two">שעתיים</item> + <item quantity="other">%d שעות</item> + </plurals> + <plurals name="days"> + <item quantity="one">יום %s</item> + <item quantity="two">יומיים</item> + <item quantity="other">%s ימים</item> + </plurals> + <plurals name="items"> + <item quantity="one">פריט %d</item> + <item quantity="two">%d פריטים</item> + <item quantity="other">%d פריטים</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">שניה %d</item> + <item quantity="two">%d שניות</item> + <item quantity="other">%d שניות</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">דקה %s</item> + <item quantity="two">%s דקות</item> + <item quantity="other">%s דקות</item> + </plurals> + <string name="mark_unwatched">סמן כלא נצפה</string> + <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="forced_track">כפוי</string> + <string name="only_forced_subtitles">כתוביות כפויות בלבד</string> + <string name="hide_controller_timeout">הסתר בקרי ניגון</string> + <string name="hide_debug_info">הסתר מידע לניפוי־שגיאות</string> + <string name="immediate">מיידי</string> + <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="no_scheduled_recordings">אין הקלטות מתוזמנות</string> + <string name="no_update_available">אין עדכון זמין</string> + <string name="path">נתיב</string> + <string name="play_count">מספר ניגונים</string> + <string name="playback_debug_info">הצג מידע לניפוי־שגיאות על הניגון</string> + <string name="preview">תצוגה מקדימה</string> + <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_recorded">הוקלט לאחרונה</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="restart">התחל מחדש</string> + <string name="resume">המשך ניגון</string> + <string name="save">שמור</string> + <string name="search_and_download"><![CDATA[חיפוש והורדה]]></string> + <string name="voice_search">חיפוש קולי</string> + <string name="voice_search_prompt">חפש בקולך</string> + <string name="processing">מעבד</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_speech_timeout">לא זוהה דיבור</string> + <string name="voice_error_timeout">הזמן לזיהוי דיבור פג</string> + <string name="voice_error_no_match">הדיבור לא הובן</string> + <string name="voice_error_busy">זיהוי דיבור לא זמין</string> + <string name="voice_error_server">שגיאת שרת</string> + <string name="voice_error_unknown">שגיאה לא ידועה</string> + <string name="voice_error_start_failed">נכשל באתחול זיהוי הדיבור</string> + <string name="retry">נסה שוב</string> + <string name="show_debug_info">הצג מידע לניפוי־שגיאות</string> + <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="sort_by_date_played">תאריך ניגון</string> + <string name="sort_by_date_released">תאריך יציאה</string> + <string name="sort_by_name">שם</string> + <string name="sort_by_random">אקראי</string> + <string name="studios">אולפנים</string> + <string name="submit">שלח</string> + <string name="voice_starting">מאתחל</string> + <string name="subtitle_download_too_long">ההורדה לוקחת זמן רב, ייתכן שיצטרכו לנגן מחדש</string> + <string name="suggestions">הצעות</string> + <string name="switch_user">החלף</string> + <string name="top_unwatched">עם הדירוג הגבוה ביותר שלא נצפו</string> + <string name="tv_dvr_schedule">תזמון הקלטות</string> + <string name="tv_guide">מדריך</string> + <string name="unknown">לא ידוע</string> + <string name="updates">עדכונים</string> + <string name="version">גרסה</string> + <string name="video_scale">קנה מידה של הוידאו</string> + <string name="video">וידאו</string> + <string name="watch_live">צפה בשידור חי</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="one_click_pause">השהה בלחיצה אחת</string> + <string name="one_click_pause_summary_on">לחץ על מרכז כפתור הניווט להשהייה/ניגון</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_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="two">שירי־נושא</item> + <item quantity="other">שירי־נושא</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">סרטון־נושא</item> + <item quantity="two">סרטוני־נושא</item> + <item quantity="other">סרטוני־נושא</item> + </plurals> + <plurals name="clips"> + <item quantity="one">קליפ</item> + <item quantity="two">קליפים</item> + <item quantity="other">קליפים</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">סצינה גנוזה</item> + <item quantity="two">סצינות גנוזות</item> + <item quantity="other">סצינות גנוזות</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">ראיון</item> + <item quantity="two">ראיונות</item> + <item quantity="other">ראיונות</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">סצינה</item> + <item quantity="two">סצינות</item> + <item quantity="other">סצינות</item> + </plurals> + <plurals name="samples"> + <item quantity="one">דגימה</item> + <item quantity="two">דגימות</item> + <item quantity="other">דגימות</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">קטע־בונוס</item> + <item quantity="two">קטעי־בונוס</item> + <item quantity="other">קטעי־בונוס</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">קטע קצר</item> + <item quantity="two">קטעים קצרים</item> + <item quantity="other">קטעים קצרים</item> + </plurals> + <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> + <string name="app_theme">ערכת נושא של האפליקציה</string> + <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="combine_continue_next"><![CDATA[מזג המשך צפייה והבא בתור]]></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="global_content_scale">קנה מידה ברירת המחדל של התוכן</string> + <string name="install_update">התקן עדכון</string> + <string name="installed_version">גרסה מותקנת</string> + <string name="max_homepage_items">מספר פריטים מקסימלי בשורות מסך הבית</string> + <string name="nav_drawer_pins_summary">בחר בפריטים ברירת המחדל להצגה, אחרים יוסתרו</string> + <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="play_theme_music">נגן מוזיקת נושא</string> + <string name="playback_overrides">עקיפת הגדרות ניגון</string> + <string name="rewatch_next_up">אפשר צפייה חוזרת ב־\"הבא בתור\"</string> + <string name="reset">איפוס</string> + <string name="bold_font">גופן מודגש</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> + <string name="exoplayer_options">אפשרויות ExoPlayer</string> + <string name="skip_segment_unknown">דלג על המקטע</string> + <string name="skip_segment_commercial">דלג על הפרסומות</string> + <string name="skip_segment_preview">דלג על התצוגה המקדימה</string> + <string name="skip_segment_recap">דלג על התקציר</string> + <string name="skip_segment_outro">דלג על כתוביות הסיום</string> + <string name="skip_segment_intro">דלג על הפתיח</string> + <string name="played">נוגן</string> + <string name="filter">סנן</string> + <string name="year">שנה</string> + <string name="decade">עשור</string> + <string name="remove">הסר</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="videos">סרטוני וידאו</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZאבגדהוזחטיכלמנסעפצקרשת</string> + <string name="seek_bar_steps">מדרגות דילוג בפס הניגון</string> + <string name="send_app_logs_summary">שימושי לניפוי־שגיאות</string> + <string name="send_app_logs">שלח לוגים של האפליקציה לשרת הנוכחי</string> + <string name="send_crash_reports_summary">ניסיון שליחה לשרת האחרון שחובר יתבצע</string> + <string name="send_crash_reports">שלח דוחות קריסה</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> + <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">כתובת לבדיקה של עדכונים לאפליקציה</string> + <string name="update_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_color">צבע קו־מתאר</string> + <string name="background_opacity">אטימות רקע</string> + <string name="background_style">סגנון רקע</string> + <string name="subtitle_style">סגנון כתוביות</string> + <string name="background_color">צבע רקע</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="subtitle_margin">שוליים</string> + <string name="verbose_logging">תיעוד לוגים מפורט</string> + <string name="enter_pin">הכנס PIN</string> + <string name="use_server_credentials">היכנס באמצעות השרת</string> + <string name="press_enter_to_confirm">לחץ על האמצע כדי לאשר</string> + <string name="require_pin_code">דרוש PIN לפרופילים</string> + <string name="confirm_pin">אמת PIN</string> + <string name="incorrect">שגוי</string> + <string name="pin_too_short">ה־PIN חייב להיות 4 מקשים לפחות</string> + <string name="will_remove_pin">ה־PIN יימחק</string> + <string name="image_cache_size">גודל מטמון על הדיסק עבור תמונות (MB)</string> + <string name="view_options">אפשרויות תצוגה</string> + <string name="columns">עמודות</string> + <string name="spacing">ריווח</string> + <string name="aspect_ratio">יחס קנה־מידה</string> + <string name="show_details">הצג פרטים</string> + <string name="image_type">סוג תמונה</string> + <string name="cast_and_crew"><![CDATA[שחקנים וצוות]]></string> + <string name="guest_stars">שחקנים אורחים</string> + <string name="edge_size">גודל קצה</string> + <string name="refresh_rate_switching">החלפת קצב רענון־תצוגה</string> + <string name="automatic">אוטומטי</string> + <string name="username_or_password">שימוש בשם משתמש וסיסמה</string> + <string name="general">כללי</string> + <string name="container">מעטפת</string> + <string name="title">כותרת</string> + <string name="codec">מקודד</string> + <string name="profile">פרופיל</string> + <string name="level">רמה</string> + <string name="resolution">רזולוציה</string> + <string name="interlaced">סריקה־שלובה</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_primaries">צבעי יסוד</string> + <string name="pixel_format">פורמט פיקסל</string> + <string name="ref_frames">פריימי־ייחוס</string> + <string name="language">שפה</string> + <string name="layout">פריסה</string> + <string name="channels">ערוצים</string> + <string name="sample_rate">קצב דגימה</string> + <string name="yes">כן</string> + <string name="no">לא</string> + <string name="bit_unit">ביט</string> + <string name="sample_rate_unit">הרץ</string> + <string name="show_titles">הצג כותרות</string> + <string name="favorite_channels_at_beginning">הצג ערוצים מועדפים ראשונים</string> + <string name="sort_channels_recently_watched">מיין ערוצים לפי \"נצפו לאחרונה\"</string> + <string name="subtitle_delay">היסט כתוביות</string> + <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="clear_track_choices">ניקוי בחירת רצועות</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> + <string name="pending">ממתין</string> + <string name="seerr_integration">אינטגרציה עם Seerr</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">כתובת</string> + <string name="trending">פופולרי עכשיו</string> + <string name="upcoming_movies">סרטים שיוצאים בקרוב</string> + <string name="upcoming_tv">סדרות שיוצאות בקרוב</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="hdr_subtitle_style">סגנון כתוביות HDR</string> + <string name="brightness">בהירות</string> + <string name="contrast">ניגודיות</string> + <string name="saturation">רוויה</string> + <string name="hue">גוון</string> + <string name="red">אדום</string> + <string name="green">ירוק</string> + <string name="blue">כחול</string> + <string name="blur">טשטוש</string> + <string name="save_for_album">שמירה לאלבום</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="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="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_compact">Wolfskin קומפקטי</string> + <string name="display_preset_series_thumb">תמונות־ממוזערות של סדרה</string> + <string name="display_preset_episode_thumbnails">תמונות־ממוזערות של פרק</string> + <string name="bold_blue">כחול עז</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="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="backdrop_style_dynamic">תמונה עם צבע דינאמי</string> + <string name="backdrop_style_image">תמונה בלבד</string> + <string name="enter_url_api_key"><![CDATA[הכנס כתובת ומפתח 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="nal">NAL</string> + <string name="avc">AVC</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="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="anamorphic">אנאמורפי</string> + <string name="image_subtitle_opacity">אטימות כתוביות מבוססות־תמונה</string> + <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 new file mode 100644 index 00000000..50efdb0c --- /dev/null +++ b/app/src/main/res/values-lv/strings.xml @@ -0,0 +1,311 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Par</string> + <string name="active_recordings">Aktīvie ieraksti</string> + <string name="add_favorite">Izlase</string> + <string name="add_server">Pievienot Serveri</string> + <string name="add_user">Pievienot Lietotāju</string> + <string name="audio">Audio celiņš</string> + <string name="birthplace">Dzimšanas vieta</string> + <string name="bitrate">Bitu ātrums</string> + <string name="born">Dzimis</string> + <string name="cancel_recording">Atcelt Ierakstu</string> + <string name="cancel_series_recording">Atcelt Seriālu Ierakstu</string> + <string name="cancel">Atcelt</string> + <string name="chapters">Nodaļas</string> + <string name="choose_stream">Izvēlēties %1$s</string> + <string name="clear_image_cache">Dzēst attēlu kešatmiņu</string> + <string name="collection">Kolekcija</string> + <string name="collections">Kolekcijas</string> + <string name="commercial">Reklāma</string> + <string name="community_rating">Skatītāju vērtējums</string> + <string name="compose_error_message_title">Radās kļūda! Nospiediet pogu, lai sūtītu žurnālus uz savu serveri.</string> + <string name="confirm">Apstiprināt</string> + <string name="continue_watching">Turpināt skatīties</string> + <string name="critic_rating">Kritiķu vērtējums</string> + <string name="decimal_seconds">%.2f sekundes</string> + <string name="default_track">Noklusējums</string> + <string name="delete">Dzēst</string> + <string name="died">Miris</string> + <string name="directed_by">Režisējis %1$s</string> + <string name="director">Režisors</string> + <string name="disabled">Atslēgts</string> + <string name="discovered_servers">Atrastie serveri</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> + <string name="enter_server_url">Ievadi Servera IP vai URL</string> + <string name="enter_server_address">Ievadi servera adresi</string> + <string name="episodes">Sērijas</string> + <string name="error_loading_collection">Kļūda, ielādējot kolekciju %1$s</string> + <string name="external_track">Ārējais</string> + <string name="favorites">Izlases</string> + <string name="file_size">Izmērs</string> + <string name="forced_track">Piespiedu</string> + <string name="genres">Žanri</string> + <string name="go_to_series">Iet uz sērijām</string> + <string name="go_to">Iet Uz</string> + <string name="hide_controller_timeout">Paslēpt atskaņošanas kontroles</string> + <string name="hide_debug_info">Paslēpt atkļūdošanas info</string> + <string name="hide">Paslēpt</string> + <string name="home">Sākums</string> + <string name="immediate">Tūlītēja</string> + <string name="intro">Ievads</string> + <string name="library">Bibliotēka</string> + <string name="license_info">Licences informācija</string> + <string name="live_tv">Tiešraides</string> + <string name="loading">Ielādē…</string> + <string name="mark_entire_series_as_played">Atzīmēt visas sērijas kā redzētas?</string> + <string name="mark_entire_series_as_unplayed">Atzīmēt visas sērijas kā neredzētas?</string> + <string name="mark_unwatched">Atzīmēt kā neredzētu</string> + <string name="mark_watched">Atzīmēt kā redzētu</string> + <string name="max_bitrate">Maksimālais bitu ātrums</string> + <string name="more_like_this">Līdzīgs saturs</string> + <string name="more">Vairāk</string> + <plurals name="movies"> + <item quantity="zero">Filmas</item> + <item quantity="other">Filmas</item> + </plurals> + <string name="name">Nosaukums</string> + <string name="next_up">Rindā</string> + <string name="no_data">Nav datu</string> + <string name="no_results">Nav rezultātu</string> + <string name="no_servers_found">Serveri netika atrasti</string> + <string name="no_scheduled_recordings">Nav ieplānoti ieraksti</string> + <string name="no_update_available">Nav pieejami atjauninājumi</string> + <string name="none">Neviens</string> + <string name="only_forced_subtitles">Tikai Piespiedu Subtitri</string> + <string name="outro">Nobeigums</string> + <string name="path">Ceļš</string> + <plurals name="people"> + <item quantity="zero">Cilvēki</item> + <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> + <string name="play">Atskaņot</string> + <string name="playback_debug_info">Rādīt atkļūdošanas informāciju</string> + <string name="playback_speed">Atskaņošanas ātrums</string> + <string name="playback">Atskaņošana</string> + <string name="playlist">Saraksts</string> + <string name="playlists">Saraksti</string> + <string name="preview">Priekšskatījums</string> + <string name="profile_specific_settings">Lietotāja Profila Iestatījumi</string> + <string name="queue">Rinda</string> + <string name="recap">Atskats</string> + <string name="recently_added_in">Nesen pievienots sadaļā %1$s</string> + <string name="recently_added">Nesen pievienots</string> + <string name="recently_recorded">Nesenie Ieraksti</string> + <string name="recently_released">Nesen Iznācis</string> + <string name="recommended">Ieteicamie</string> + <string name="record_program">Ierakstīt Raidījumu</string> + <string name="record_series">Ierakstīt Sērijas</string> + <string name="remove_favorite">Noņemt no izlases</string> + <string name="restart">Restartēt</string> + <string name="resume">Turpināt</string> + <string name="save">Saglabā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> + <string name="voice_starting">Uzsāk</string> + <string name="voice_search_prompt">Runā lai meklētu</string> + <string name="processing">Apstrādā</string> + <string name="press_back_to_cancel">Spied atpakaļ lai atceltu</string> + <string name="voice_error_audio">Audio ieraksta kļūda</string> + <string name="voice_error_client">Balss atpazīšanas kļūda</string> + <string name="voice_error_permissions">Mikrofona tiesību kļūda</string> + <string name="voice_error_network">Tīkla kļūda</string> + <string name="voice_error_network_timeout">Tīkla noilgums</string> + <string name="voice_error_no_match">Balss netika atpazīta</string> + <string name="voice_error_busy">Balss atpazīšana aizņemta</string> + <string name="voice_error_server">Servera kļūda</string> + <string name="voice_error_speech_timeout">Neviena balss netika atklāta</string> + <string name="voice_error_unknown">Nezināma kļūda</string> + <string name="voice_error_start_failed">Kļūda sākot balss atpazīšanu</string> + <string name="voice_error_timeout">Balss atpazīšanai iestājies noilgums</string> + <string name="retry">Mēģināt vēlreiz</string> + <string name="select_server">Izvēlies Serveri</string> + <string name="select_user">Izvēlies Lietotāju</string> + <string name="show_debug_info">Rādīt atkļūdošanas info</string> + <string name="show_next_up_when">Rādīt \"Skaties tālāk\"</string> + <string name="show">Rādīt</string> + <string name="shuffle">Sajaukt</string> + <string name="skip">Izlaist</string> + <string name="sort_by_date_added">Pievienošanas Datums</string> + <string name="sort_by_date_episode_added">Sērijas pievienošanas datums</string> + <string name="sort_by_date_played">Atskaņošanas Datums</string> + <string name="sort_by_date_released">Izdošanas Datums</string> + <string name="sort_by_name">Nosaukums</string> + <string name="sort_by_random">Nejaušs</string> + <string name="studios">Studija</string> + <string name="submit">Iesniegt</string> + <string name="subtitle_download_too_long">Lejupielāde ir lēna, iespējams jāmēģina restartēt video</string> + <string name="subtitle">Subtitri</string> + <string name="subtitles">Subtitri</string> + <string name="suggestions">Ieteikumi</string> + <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> + <plurals name="trailers"> + <item quantity="zero">Treileri</item> + <item quantity="other">Treileri</item> + </plurals> + <string name="tv_dvr_schedule">Ierakstu plāns</string> + <string name="tv_guide">Programma</string> + <string name="tv_season">Sezona</string> + <string name="tv_seasons">Sezonas</string> + <plurals name="tv_shows"> + <item quantity="zero">TV Šovi</item> + <item quantity="other">TV Šovi</item> + </plurals> + <string name="ui_interface">Saskarne</string> + <string name="unknown">Nezināms</string> + <string name="updates">Atjauninājumi</string> + <string name="version">Versija</string> + <string name="video_scale">Video Mērogs</string> + <string name="video">Video</string> + <string name="videos">Video</string> + <string name="watch_live">Skatīties tiešraidi</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> + <string name="aired_episode_order">Iznākušo Sēriju Secība</string> + <string name="create_playlist">Izveidot jaunu izlasi</string> + <string name="add_to_playlist">Pievienot izlasei</string> + <string name="one_click_pause">Apturēt ar vienu klikšķi</string> + <string name="one_click_pause_summary_on">Nospiediet pults vidu, lai pauzētu/atskaņotu</string> + <string name="italic_font">Slīpraksts</string> + <string name="font">Teksts</string> + <string name="background">Fons</string> + <string name="success">Veiksmīgi</string> + <string name="official_rating">Vecuma ierobežojums</string> + <string name="runtime_sort">Ilgums</string> + <string name="extras_title">Ekstras</string> + <plurals name="other_extras"> + <item quantity="zero">Cits</item> + <item quantity="other">Cits</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="zero">Aizkadri</item> + <item quantity="other">Aizkadri</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="zero">Tituldziesmas</item> + <item quantity="other">Tituldziesmas</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="zero">Titulvideo</item> + <item quantity="other">Titulvideo</item> + </plurals> + <plurals name="clips"> + <item quantity="zero">Klipi</item> + <item quantity="other">Klipi</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="zero">Izgrieztās ainas</item> + <item quantity="other">Izgrieztās ainas</item> + </plurals> + <plurals name="interviews"> + <item quantity="zero">Intervijas</item> + <item quantity="other">Intervijas</item> + </plurals> + <plurals name="scenes"> + <item quantity="zero">Ainas</item> + <item quantity="other">Ainas</item> + </plurals> + <plurals name="samples"> + <item quantity="zero">Paraugi</item> + <item quantity="other">Paraugi</item> + </plurals> + <plurals name="featurettes"> + <item quantity="zero">Īssižeti</item> + <item quantity="other">Īssižeti</item> + </plurals> + <plurals name="shorts"> + <item quantity="zero">Īsie rullīši</item> + <item quantity="other">Īsie rullīši</item> + </plurals> + <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> + <item quantity="other">%s lejupielādes</item> + </plurals> + <plurals name="hours"> + <item quantity="zero">%d stundas</item> + <item quantity="one">%d stunda</item> + <item quantity="other">%d stundas</item> + </plurals> + <plurals name="days"> + <item quantity="zero">%s dienas</item> + <item quantity="one">%s diena</item> + <item quantity="other">%s dienas</item> + </plurals> + <plurals name="items"> + <item quantity="zero">%d elementi</item> + <item quantity="one">%d elements</item> + <item quantity="other">%d elementi</item> + </plurals> + <plurals name="seconds"> + <item quantity="zero">%d sekundes</item> + <item quantity="one">%d sekunde</item> + <item quantity="other">%d sekundes</item> + </plurals> + <plurals name="minutes"> + <item quantity="zero">%s minūtes</item> + <item quantity="one">%s minūte</item> + <item quantity="other">%s minūtes</item> + </plurals> + <string name="settings">Iestatījumi</string> + <string name="reset">Atiestatīt</string> + <string name="played">Atskaņots</string> + <string name="saturation">Piesātinājums</string> + <string name="hue">Tonis</string> + <string name="red">Sarkans</string> + <string name="green">Zaļš</string> + <string name="blue">Zils</string> + <string name="albums">Albumi</string> + <string name="songs">Dziesmas</string> + <string name="height">Augstums</string> + <string name="screensaver">Ekrānsaudzētājs</string> + <string name="duration">Ilgums</string> + <string name="year">Gads</string> + <string name="decade">Desmitgade</string> + <string name="remove">Noņemt</string> + <string name="columns">Kolonnas</string> + <string name="spacing">Atstarpe</string> + <string name="automatic">Automātiski</string> + <string name="container">Konteiners</string> + <string name="title">Nosaukums</string> + <string name="codec">Kodeks</string> + <string name="profile">Profils</string> + <string name="level">Līmenis</string> + <string name="resolution">Izšķirtspēja</string> + <string name="framerate">Kadru ātrums/skaits</string> + <string name="language">Valoda</string> + <string name="channels">Kanāli</string> + <string name="avc">AVC</string> + <string name="yes">Jā</string> + <string name="no">Nē</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">bits</string> + <string name="sample_rate_unit">Hz</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</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="discover">Atklāt</string> + <string name="password">Parole</string> + <string name="username">Lietotājvārds</string> +</resources> 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 7068d379..1e4891ba 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2,7 +2,7 @@ <resources> <string name="about">Over</string> <string name="active_recordings">Actieve opnames</string> - <string name="add_favorite">Toev. aan favorieten</string> + <string name="add_favorite">Aan favorieten toevoegen</string> <string name="add_server">Server toevoegen</string> <string name="add_user">Gebruiker toevoegen</string> <string name="audio">Audio</string> @@ -13,41 +13,41 @@ <string name="cancel_series_recording">Serieopname afbreken</string> <string name="cancel">Annuleren</string> <string name="chapters">Hoofdstukken</string> - <string name="choose_stream">‘%1$s’ kiezen</string> + <string name="choose_stream">%1$s kiezen</string> <string name="clear_image_cache">Afbeeldingscache wissen</string> <string name="collection">Collectie</string> <string name="collections">Collecties</string> <string name="commercial">Reclame</string> - <string name="community_rating">Gemeenschapsbeoordeling</string> + <string name="community_rating">Beoordeling (publiek)</string> <string name="compose_error_message_title">Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen.</string> <string name="confirm">Bevestigen</string> - <string name="continue_watching">Hervatten</string> - <string name="critic_rating">Critici-beoordeling</string> - <string name="decimal_seconds">%.1f seconden</string> + <string name="continue_watching">Verder kijken</string> + <string name="critic_rating">Beoordeling (recensie)</string> + <string name="decimal_seconds">%.2f seconden</string> <string name="default_track">Standaard</string> <string name="delete">Verwijderen</string> <string name="died">Overleden</string> <string name="directed_by">Geregisseerd door %1$s</string> <string name="director">Regisseur</string> <string name="disabled">Uitgeschakeld</string> - <string name="discovered_servers">Aangetroffen servers</string> + <string name="discovered_servers">Gevonden servers</string> <string name="download_and_update"><![CDATA[Downloaden en bijwerken]]></string> <string name="downloading">Bezig met downloaden…</string> <string name="enabled">Ingeschakeld</string> - <string name="enter_server_url">Voer het IP-adres of de url van de server in</string> + <string name="enter_server_url">Voer IP-adres of url van server in</string> <string name="episodes">Afleveringen</string> - <string name="error_loading_collection">‘%1$s’ kan niet worden geladen</string> + <string name="error_loading_collection">Fout bij laden %1$s collectie</string> <string name="external_track">Extern</string> <string name="favorites">Favorieten</string> <string name="file_size">Grootte</string> - <string name="forced_track">Afgedwongen</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> - <string name="home">Overzicht</string> + <string name="home">Home</string> <string name="immediate">Onmiddellijk</string> <string name="intro">Intro</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> @@ -55,16 +55,16 @@ <string name="license_info">Licentie-informatie</string> <string name="live_tv">Live-tv</string> <string name="loading">Bezig met laden…</string> - <string name="mark_entire_series_as_played">Wil je de gehele serie als bekeken markeren?</string> - <string name="mark_entire_series_as_unplayed">Wil je de gehele serie als niet-bekeken markeren?</string> - <string name="mark_unwatched">Markeren als niet-bekeken</string> - <string name="mark_watched">Markeren als bekeken</string> + <string name="mark_entire_series_as_played">Serie als bekeken markeren?</string> + <string name="mark_entire_series_as_unplayed">Serie als niet-bekeken markeren?</string> + <string name="mark_unwatched">Als niet-bekeken markeren</string> + <string name="mark_watched">Als bekeken markeren</string> <string name="max_bitrate">Max. bitsnelheid</string> - <string name="more_like_this">Gerelateerd</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">Hierna</string> + <string name="next_up">Volgende</string> <string name="no_data">Geen informatie</string> <string name="no_results">Geen zoekresultaten</string> <string name="no_scheduled_recordings">Geen ingeplande opnames</string> @@ -72,9 +72,9 @@ <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">Hervatten vanaf hier</string> + <string name="play_from_here">Vanaf hier afspelen</string> <string name="play">Afspelen</string> <string name="playback_debug_info">Foutopsporingsinformatie tonen</string> <string name="playback_speed">Afspeelsnelheid</string> @@ -84,21 +84,21 @@ <string name="preview">Voorvertoning</string> <string name="profile_specific_settings">Profielinstellingen</string> <string name="queue">Wachtrij</string> - <string name="recap">Samenvatten</string> - <string name="recently_added_in">Onlangs toegevoegd aan ‘%1$s’</string> + <string name="recap">Samenvatting</string> + <string name="recently_added_in">Onlangs toegevoegd aan %1$s</string> <string name="recently_added">Onlangs toegevoegd</string> <string name="recently_recorded">Onlangs opgenomen</string> - <string name="recently_released">Onlangs uitgebracht</string> + <string name="recently_released">Onlangs verschenen</string> <string name="recommended">Aanbevolen</string> <string name="record_program">Programma opnemen</string> <string name="record_series">Serie opnemen</string> - <string name="remove_favorite">Verw. uit favorieten</string> + <string name="remove_favorite">Uit favorieten verwijderen</string> <string name="restart">Herstarten</string> <string name="resume">Hervatten</string> <string name="save">Opslaan</string> <string name="search_and_download"><![CDATA[Zoeken en downloaden]]></string> <string name="search">Zoeken</string> - <string name="searching">Bezig met zoeken…</string> + <string name="searching">Aan het zoeken…</string> <string name="select_server">Kies een server</string> <string name="select_user">Kies een gebruiker</string> <string name="show_debug_info">Foutopsporingsinformatie tonen</string> @@ -106,31 +106,30 @@ <string name="show">Tonen</string> <string name="shuffle">Willekeurig</string> <string name="skip">Overslaan</string> - <string name="sort_by_date_added">Toegevoegd op</string> - <string name="sort_by_date_episode_added">Toegevoegd op (aflevering)</string> - <string name="sort_by_date_played">Bekeken op</string> - <string name="sort_by_date_released">Uitgebracht op</string> + <string name="sort_by_date_added">Toegevoegd</string> + <string name="sort_by_date_episode_added">Toegevoegd (aflevering)</string> + <string name="sort_by_date_played">Bekeken</string> + <string name="sort_by_date_released">Verschenen</string> <string name="sort_by_name">Naam</string> <string name="sort_by_random">Willekeurig</string> <string name="studios">Studio\'s</string> <string name="submit">Versturen</string> <string name="subtitle_download_too_long">Het downloaden duurt erg lang - het kan zijn dat je opnieuw moet beginnen met afspelen</string> - <string name="subtitle">Ondertiteling</string> + <string name="subtitle">Onderschrift</string> <string name="subtitles">Ondertiteling</string> <string name="suggestions">Suggesties</string> <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> + <string name="top_unwatched">Best beoordeeld niet-bekeken</string> <plurals name="trailers"> - <item quantity="one">Trailers</item> - <item quantity="other"/> + <item quantity="one">Trailer</item> + <item quantity="other">Trailers</item> </plurals> <string name="tv_dvr_schedule">DVR-schema</string> <string name="tv_guide">Gids</string> <string name="tv_season">Seizoen</string> <string name="tv_seasons">Seizoenen</string> - <string name="tv_shows">Tv-shows</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,64 +138,66 @@ <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> <string name="create_playlist">Afspeellijst maken</string> - <string name="add_to_playlist">Toevoegen aan afspeellijst</string> + <string name="add_to_playlist">Aan afspeellijst toevoegen</string> <string name="one_click_pause">Pauzeren met één druk op de knop</string> <string name="one_click_pause_summary_on">Druk op de middelste knop van het d-pad om af te spelen/te pauzeren</string> <string name="italic_font">Cursief lettertype gebruiken</string> <string name="font">Lettertype</string> <string name="background">Achtergrond</string> <string name="success">Voltooid</string> - <string name="official_rating">Kijkwijzerbeoordeling</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> @@ -223,8 +224,8 @@ <string name="auto_play_next">Volgende in wachtrij automatisch afspelen</string> <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 hierna afspelen combineren]]></string> - <string name="direct_play_ass">ASS-ondertiteling automatisch tonen</string> + <string name="combine_continue_next"><![CDATA[Hervatten en Volgende combineren]]></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> @@ -249,14 +250,14 @@ <string name="settings">Instellingen</string> <string name="skip_back_on_resume_preference">Terugspoelen na hervatten</string> <string name="skip_back_preference">Terugspoelen</string> - <string name="skip_commercials_behavior">Instellingen omtrent overslaan van reclame</string> + <string name="skip_commercials_behavior">Reclame</string> <string name="skip_forward_preference">Vooruitspoelen</string> - <string name="skip_intro_behavior">Instellingen omtrent overslaan van intro\'s</string> - <string name="skip_outro_behavior">Instellingen omtrent overslaan van aftitelingen</string> - <string name="skip_previews_behavior">Instellingen omtrent overslaan van voorvertoningen</string> - <string name="skip_recap_behavior">Instellingen omtrent overslaan van samenvattingen</string> + <string name="skip_intro_behavior">Intro\'s</string> + <string name="skip_outro_behavior">Aftitelingen</string> + <string name="skip_previews_behavior">Voorvertoningen</string> + <string name="skip_recap_behavior">Samenvattingen</string> <string name="update_available">Update beschikbaar</string> - <string name="update_url_summary">De url die moet worden gebruikt voor het controleren op updates</string> + <string name="update_url_summary">URL gebruikt voor het controleren op updates</string> <string name="update_url">Update-url</string> <string name="font_size">Tekstgrootte</string> <string name="font_color">Tekstkleur</string> @@ -311,7 +312,7 @@ <string name="cast_and_crew"><![CDATA[Cast en crew]]></string> <string name="guest_stars">Gastrollen</string> <string name="edge_size">Randbreedte</string> - <string name="refresh_rate_switching">Automatische ververssnelheid toepassen</string> + <string name="refresh_rate_switching">Beeldfrequentie omschakelen</string> <string name="automatic">Automatisch</string> <string name="username_or_password">Gebruikersnaam/wachtwoord gebruiken</string> <string name="login">Inloggen</string> @@ -325,7 +326,7 @@ <string name="level">Niveau</string> <string name="resolution">Resolutie</string> <string name="anamorphic">Anamorfisch</string> - <string name="interlaced">Met interlatie</string> + <string name="interlaced">Geïnterlinieerd</string> <string name="framerate">Framesnelheid</string> <string name="bit_depth">Bitdiepte</string> <string name="video_range">Videobereik</string> @@ -354,4 +355,311 @@ <string name="favorite_channels_at_beginning">Favoriete kanalen eerst tonen</string> <string name="sort_channels_recently_watched">Kanalen sorteren op onlangs bekeken</string> <string name="color_code_programs">Programma\'s voorzien van kleurcode</string> + <string name="ends_at">Eindigt om %1$s</string> + <string name="enter_server_address">Voer server-adres in</string> + <string name="discover">Ontdekken</string> + <string name="rotate_left">Draai naar links</string> + <string name="rotate_right">Draai naar rechts</string> + <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_title">Favoriete %s</string> + <string name="duration">Duur</string> + <string name="add_row">Rij toevoegen</string> + <string name="for_all_ages">Alle leeftijden</string> + <string name="username">Gebruikersnaam</string> + <string name="up_to_age">Leeftijd tot %s</string> + <string name="saturation">Verzadiging</string> + <string name="guest_star">Gast hoofdrol</string> + <string name="remove_from_queue">Verwijder uit wachtrij</string> + <string name="seerr_login">Login bij Seerr server</string> + <string name="search_for">Zoek %s</string> + <string name="actor">Acteur</string> + <string name="clear_track_choices">Afspeelvoorkeuren wissen</string> + <string name="display_preset_episode_thumbnails">Aflevering thumbnails</string> + <string name="writer">Auteur</string> + <string name="for_episodes">Voor afleveringen</string> + <string name="start_after">Start na</string> + <string name="add_to_queue">Aan wachtrij toevoegen</string> + <string name="composer">Componist</string> + <string name="display_preset_series_thumb">Serie thumbnails</string> + <string name="delete_item">Weet u zeker dat u dit item wilt verwijderen?</string> + <string name="settings_saved">Instellingen opgeslagen</string> + <string name="continue_string">Ga door</string> + <string name="music_videos">Muziekvideos</string> + <string name="max_days_next_up">Max. aantal dagen in Volgende</string> + <string name="now_playing">Wordt nu afgespeeld</string> + <string name="request">Aanvragen</string> + <string name="play_trailer">Trailer afspelen</string> + <string name="no_servers_found">Geen servers gevonden</string> + <string name="only_forced_subtitles">Alleen geforceerde ondertiteling</string> + <string name="retry">Opnieuw proberen</string> + <string name="subtitle_delay">Vertraging ondertiteling</string> + <string name="no_trailers">Geen trailers</string> + <string name="pending">In behandeling</string> + <string name="password">Wachtwoord</string> + <string name="upcoming_movies">Verwachte films</string> + <string name="upcoming_tv">Verwachte series</string> + <string name="request_4k">Aanvragen in 4K</string> + <string name="brightness">Helderheid</string> + <string name="send_media_info_log_to_server">Media-info log naar server</string> + <string name="resolution_switching">Resolutie schakelen</string> + <string name="local">Lokaal</string> + <string name="backdrop_display">Achtergrondstijl</string> + <string name="force_dovi_profile_7_summary">Negeer compatibiliteitscontrole apparaat</string> + <string name="voice_search">Gesproken zoekopdracht</string> + <string name="voice_search_prompt">Spreek om te zoeken</string> + <string name="voice_starting">Starten</string> + <string name="processing">Verwerken</string> + <string name="press_back_to_cancel">Terugknop om te annuleren</string> + <string name="voice_error_audio">Audio opnamefout</string> + <string name="voice_error_client">Spraakherkenningsfout</string> + <string name="voice_error_permissions">Toestemming microfoon vereist</string> + <string name="voice_error_network">Netwerkfout</string> + <string name="voice_error_network_timeout">Netwerk timeout</string> + <string name="voice_error_no_match">Geen spraak herkent</string> + <string name="voice_error_busy">Spraakherkenning bezig</string> + <string name="voice_error_server">Serverfout</string> + <string name="voice_error_speech_timeout">Geen spraak gedetecteerd</string> + <string name="voice_error_unknown">Onbekende fout</string> + <string name="voice_error_start_failed">Fout bij starten spraakherkenning</string> + <string name="voice_error_timeout">Spraakherkenning timed out</string> + <string name="seerr_integration">Seerr integratie</string> + <string name="remove_seerr_server">Verwijder Seerr server</string> + <string name="seerr_server_added">Seerr server toegevoegd</string> + <string name="hdr_subtitle_style">HDR ondertitelstijl</string> + <string name="image_subtitle_opacity">Dekking ondertitelbeeld</string> + <string name="zoom_in">Zoom in</string> + <string name="no_limit">Geen limiet</string> + <string name="customize_home">Startscherm aanpassen</string> + <string name="home_rows">Startrijen</string> + <string name="suggestions_for">Aanbevelingen voor %1$s</string> + <string name="volume_lowest">Laagst</string> + <string name="skip_automatically">Automatisch overslaan</string> + <string name="conductor">Dirigent</string> + <string name="producer">Producent</string> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dagen</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuut</item> + <item quantity="other">%s minuten</item> + </plurals> + <string name="quick_connect_summary">Een ander apparaat toestaan om in te loggen op uw account</string> + <string name="quick_connect_code">Quick Connect code invoeren</string> + <string name="quick_connect_code_error">Code moet 6 cijfers bevatten</string> + <string name="quick_connect_success">Apparaat succesvol geautoriseerd</string> + <string name="upgrade_mpv_toast">MPV is nu de standaard speler behalve voor HDR. \nU kunt dit in de instellingen wijzigen.</string> + <string name="save_for_album">Bewaar voor album</string> + <string name="play_slideshow">Diavoorstelling afspelen</string> + <string name="stop_slideshow">Diavoorstelling stoppen</string> + <string name="slideshow_at_beginning">Aan het begin</string> + <string name="no_more_images">Geen foto\'s meer</string> + <string name="slideshow_duration">Duur diavoorstelling</string> + <string name="play_videos_during_slideshow">Speel video\'s tijdens diavoorstelling</string> + <string name="artists">Artiesten</string> + <string name="songs">Nummers</string> + <string name="increase_all_cards_size">Alle kaarten vergroten</string> + <string name="decrease_all_cards_size">Alle kaarten verkleinen</string> + <string name="use_thumb_images">Kleine afbeeldingen gebruiken</string> + <string name="display_presets">Weergavevoorkeuren</string> + <string name="display_presets_description">Ingebouwde presets om snel alle rijen te stijlen</string> + <string name="customize_home_summary">Kies rijen en afbeeldingen op startscherm</string> + <string name="animate">Animeer</string> + <string name="max_age_rating">Max leeftijdsclassificatie</string> + <string name="no_max">Geen max</string> + <string name="screensaver">Schermbeveiliging</string> + <string name="screensaver_settings">Instellingen schermbeveiliging</string> + <string name="start_screensaver">Start schermbeveiliging</string> + <string name="photos">Foto\'s</string> + <string name="include_types">Soort toevoegen</string> + <string name="include_types_summary">Welke soorten afbeeldingen moeten worden getoond</string> + <string name="content_scale_fit">Passend</string> + <string name="content_scale_crop">Bijsnijden</string> + <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">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> + <string name="volume_full">Volledig</string> + <string name="purple">Paars</string> + <string name="orange">Oranje</string> + <string name="bold_blue">Felblauw</string> + <string name="black">Zwart</string> + <string name="ffmpeg_fallback">Alleen FFmpeg gebruiken als er geen ingebouwde decoder is</string> + <string name="ffmpeg_prefer">Voorkeur voor FFmpeg boven ingebouwde decoders</string> + <string name="ffmpeg_never">Nooit FFmpeg decoders gebruiken</string> + <string name="next_up_playback_end">Aan het einde van de weergave</string> + <string name="next_up_outro">Tijdens aftiteling/outro</string> + <string name="white">Wit</string> + <string name="light_gray">Lichtgrijs</string> + <string name="dark_gray">Donkergrijs</string> + <string name="yellow">Geel</string> + <string name="cyan">Cyaan</string> + <string name="subtitle_edge_outline">Omtrek</string> + <string name="subtitle_edge_shadow">Schaduw</string> + <string name="prefer_mpv">Voorkeur voor MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer voor HDR gebruiken</string> + <string name="aspect_ratios_square">Vierkant (1:1)</string> + <string name="image_type_primary">Primair</string> + <string name="image_type_thumb">Miniatuur</string> + <string name="backdrop_style_dynamic">Afbeelding met dynamische kleuren</string> + <string name="backdrop_style_image">Alleen afbeelding</string> + <string name="seerr_jellyfin_user">Jellyfin gebruiker</string> + <string name="seerr_local_user">Lokale gebruiker</string> + <string name="no_subtitles_found">Geen externe ondertiteling gevonden</string> + <string name="series_continueing">Huidig</string> + <string name="in_app_screensaver">In-app schermbeveiliging gebruiken</string> + <string name="show_media_management">Media-management opties tonen</string> + <string name="lyricist">Tekstschrijver</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Technicus</string> + <string name="creator">Bedenker</string> + <string name="artist">Artiest</string> + <string name="album_artist">Album artiest</string> + <string name="popular_songs">Populaire nummers</string> + <string name="play_next">Volgende afspelen</string> + <string name="lyrics">Tekst</string> + <string name="hide_lyrics">Tekst verbergen</string> + <string name="show_lyrics">Tekst tonen</string> + <string name="song_has_lyrics">Nummer bevat tekst</string> + <string name="show_album_cover">Albumhoes tonen</string> + <string name="show_visualizer">Visualisatie tonen</string> + <string name="show_backdrop">Achtergrond tonen</string> + <string name="play_as_type">Afspelen als?</string> + <string name="visualizer_rationale">Visualisatie van de huidig afspelende audio vereist toestemming voor opname.</string> + <string name="prefer_logos">Titels als logo weergeven</string> + <string name="select_all">Alles selecteren</string> + <string name="separate_types">Afzonderlijke types</string> + <string name="remove_continue_watching">Uit Verder kijken verwijderen</string> + <string name="remove_next_up">Uit Volgende verwijderen</string> + <string name="view_more">Bekijk meer</string> + <string name="discover_tv">Series ontdekken</string> + <string name="discover_movies">Films ontdekken</string> + <string name="view_removed_next_up">Uit Volgende verwijderd bekijken</string> + <string name="skip_behavior">Overslaan</string> + <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 b4826c5b..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> @@ -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> @@ -359,4 +360,188 @@ </plurals> <string name="nav_drawer_pins_summary">Vel standardelementa som skal visast, andre vert skjulde</string> <string name="ends_at">Sluttar %1$s</string> + <string name="only_forced_subtitles">Berre påtvinga undertekstar</string> + <string name="voice_search">Stemmesøk</string> + <string name="voice_starting">Startar</string> + <string name="voice_search_prompt">Snakk for å søkje</string> + <string name="processing">Jobbar</string> + <string name="press_back_to_cancel">Trykk tilbake for å avbryta</string> + <string name="voice_error_audio">Lydopptaksfeil</string> + <string name="voice_error_client">Feil med talegjenkjenning</string> + <string name="voice_error_permissions">Treng mikrofontilgang</string> + <string name="voice_error_network">Nettverksfeil</string> + <string name="voice_error_network_timeout">Tidsavbrot på nettverket</string> + <string name="voice_error_no_match">Fann inga tale</string> + <string name="voice_error_busy">Talegjenkjenning er oppteken</string> + <string name="voice_error_server">Tenarfeil</string> + <string name="voice_error_speech_timeout">Inga tale funne</string> + <string name="voice_error_unknown">Ukjend feil</string> + <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_title">Favoritt %s</string> + <string name="resolution_switching">Oppløysingsbyte</string> + <string name="local">Lokal</string> + <string name="play_trailer">Spel trailer</string> + <string name="no_trailers">Ingen trailerar</string> + <string name="clear_track_choices">Tøm sporval</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">Direkteavspeling av Dolby Vision-profil 7</string> + <string name="force_dovi_profile_7_summary">Ignorerer kompatibilitetssjekk på eininga</string> + <string name="discover">Oppdag</string> + <string name="request">Be om</string> + <string name="pending">Ventar</string> + <string name="seerr_integration">Seerr-integrasjon</string> + <string name="remove_seerr_server">Fjern Seerr-tenar</string> + <string name="seerr_server_added">Seerr-tenar lagt til</string> + <string name="quick_connect">Hurtigkopling</string> + <string name="quick_connect_summary">Gi ei anna eining tilgang til å logge inn på kontoen din</string> + <string name="quick_connect_code">Skriv inn hurtigkoplingskode</string> + <string name="quick_connect_code_error">Koden må vere 6 siffer</string> + <string name="quick_connect_success">Eininga fekk tilgang</string> + <string name="password">Passord</string> + <string name="username">Brukarnamn</string> + <string name="url">URL</string> + <string name="trending">Populært no</string> + <string name="upcoming_movies">Kommande filmar</string> + <string name="upcoming_tv">Kommande TV-seriar</string> + <string name="request_4k">Be om i 4K</string> + <string name="software_decoding_av1">AV1-programvaredekoding</string> + <string name="hdr_subtitle_style">HDR-tekststil</string> + <string name="image_subtitle_opacity">Gjennomsiktigheit for bilettekst</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Metning</string> + <string name="hue">Fargetone</string> + <string name="red">Raud</string> + <string name="green">Grøn</string> + <string name="blue">Blå</string> + <string name="blur">Sløring</string> + <string name="save_for_album">Lagre til album</string> + <string name="play_slideshow">Spel lysbiletevising</string> + <string name="stop_slideshow">Stopp lysbiletevising</string> + <string name="slideshow_at_beginning">Ved byrjinga</string> + <string name="no_more_images">Ingen fleire bilete</string> + <string name="rotate_left">Roter til venstre</string> + <string name="rotate_right">Roter til høgre</string> + <string name="zoom_in">Zoom inn</string> + <string name="zoom_out">Zoom ut</string> + <string name="slideshow_duration">Lengde på lysbiletevising</string> + <string name="play_videos_during_slideshow">Spel videoar i lysbiletevisinga</string> + <string name="send_media_info_log_to_server">Send logg med medieinfo til tenaren</string> + <string name="no_limit">Ingen grense</string> + <string name="max_days_next_up">Maks dagar i Neste</string> + <string name="add_row">Legg til rad</string> + <string name="genres_in">Sjangrar i %1$s</string> + <string name="recently_released_in">Nylig utgitt i %1$s</string> + <string name="height">Høgde</string> + <string name="apply_all_rows">Bruk på alle radene</string> + <string name="customize_home">Tilpass heim-sida</string> + <string name="home_rows">Rader på heim-sida</string> + <string name="load_from_server">Last frå brukarprofil på tenaren</string> + <string name="save_to_server">Lagre til brukarprofil på tenaren</string> + <string name="load_from_web_client">Last frå nettklient</string> + <string name="use_series">Bruk bilete av serien</string> + <string name="add_row_for">Legg til rad for %1$s</string> + <string name="overwrite_server_settings">Overskrive innstillingane på tenaren?</string> + <string name="overwrite_local_settings">Overskrive lokale innstillingar?</string> + <string name="for_episodes">For episodar</string> + <string name="suggestions_for">Forslag for %1$s</string> + <string name="increase_all_cards_size">Auk storleiken på alle korta</string> + <string name="decrease_all_cards_size">Mink storleiken på alle korta</string> + <string name="use_thumb_images">Bruk miniatyrbilete</string> + <string name="settings_saved">Innstillingar lagra</string> + <string name="display_presets">Visingsval</string> + <string name="display_presets_description">Innebygde val for å raskt endre stilen på alle radene</string> + <string name="customize_home_summary">Vel rader og bilete på heim-sida</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 maksgrense</string> + <string name="for_all_ages">For alle aldrar</string> + <string name="up_to_age">Opp til %s år</string> + <string name="screensaver">Skjermsparar</string> + <string name="screensaver_settings">Innstillingar for skjermsparar</string> + <string name="start_screensaver">Start skjermsparar</string> + <string name="duration">Varigheit</string> + <string name="photos">Bilete</string> + <string name="include_types">Inkluder typar</string> + <string name="include_types_summary">Kva typar element det skal visast bilete for</string> + <string name="content_scale_fit">Tilpass</string> + <string name="content_scale_crop">Beskjer</string> + <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">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> + <string name="volume_low">Låg</string> + <string name="volume_medium">Middels</string> + <string name="volume_high">Høg</string> + <string name="volume_full">Full</string> + <string name="purple">Lilla</string> + <string name="orange">Oransje</string> + <string name="bold_blue">Kraftig blå</string> + <string name="black">Svart</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">Berre bruk FFmpeg om det ikkje finst innebygd dekodar</string> + <string name="ffmpeg_prefer">Føretrekk FFmpeg framfor innebygde dekodarar</string> + <string name="ffmpeg_never">Aldri bruk FFmpeg-dekodarar</string> + <string name="next_up_playback_end">Ved slutten av avspelinga</string> + <string name="next_up_outro">Under rulletekst/outro</string> + <string name="white">Kvit</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">Omriss</string> + <string name="subtitle_edge_shadow">Skugge</string> + <string name="background_style_wrap">Omkransa</string> + <string name="background_style_boxed">Rektangulær</string> + <string name="prefer_mpv">Føretrekk MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for HDR-avspeling</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="backdrop_style_dynamic">Bilete med dynamisk farge</string> + <string name="backdrop_style_image">Berre bilete</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">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> + <string name="delete_item">Er du sikker på at du vil sletta dette elementet?</string> + <string name="show_media_management">Vis alternativ for mediehandsaming</string> + <string name="actor">Skodespelar</string> + <string name="composer">Komponist</string> + <string name="writer">Forfattar</string> + <string name="guest_star">Gjestestjerne</string> + <string name="producer">Produsent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Tekstforfattar</string> + <string name="arranger">Arrangør</string> + <string name="engineer">Teknikar</string> + <string name="mixer">Miksar</string> + <string name="creator">Skapar</string> + <string name="artist">Artist</string> + <string name="search_for">Søk %s</string> + <string name="select_all">Vel alle</string> </resources> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 53cd49fc..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> @@ -32,8 +32,8 @@ <string name="record_program">Zapisz program</string> <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="sort_by_date_episode_added">Data dodania odcinka</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> @@ -41,23 +41,22 @@ <string name="suggestions">Propozycje</string> <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> + <string name="top_unwatched">Najlepiej oceniane nieobejrzane</string> <plurals name="trailers"> - <item quantity="one">Zwiastuny</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Zwiastun</item> + <item quantity="few">Zwiastuny</item> + <item quantity="many">Zwiastunów</item> + <item quantity="other">Zwiastunów</item> </plurals> <string name="submit">Potwierdź</string> - <string name="sort_by_date_released">Data Wydania</string> + <string name="sort_by_date_released">Data wydania</string> <string name="sort_by_name">Nazwa</string> <string name="sort_by_random">Losowy</string> <string name="tv_guide">Przewodnik TV</string> - <string name="studios">Studia Filmowe</string> + <string name="studios">Studia filmowe</string> <string name="shuffle">Losuj</string> <string name="show_next_up_when">Pokaż następne</string> - <string name="recently_released">Ostatnio Wydane</string> + <string name="recently_released">Ostatnio wydane</string> <string name="play">Odtwórz</string> <string name="playback_debug_info">Pokaż informacje debugowania odtwarzacza</string> <string name="outro">Napisy końcowe</string> @@ -73,7 +72,7 @@ <string name="recently_recorded">Ostatnio Nagrane</string> <string name="recently_added_in">Ostatnio dodane w %1$s</string> <string name="resume">Wznów</string> - <string name="sort_by_date_played">Data Odtworzenia</string> + <string name="sort_by_date_played">Data odtworzenia</string> <string name="add_favorite">Ulubione</string> <string name="cancel">Anuluj</string> <string name="chapters">Rozdziały</string> @@ -88,11 +87,11 @@ <string name="hide_controller_timeout">Ukryj przyciski odtwarzania</string> <string name="hide_debug_info">Ukryj informacje o debugowaniu</string> <string name="hide">Ukryj</string> - <string name="home">Strona Główna</string> + <string name="home">Strona główna</string> <string name="favorites">Ulubione</string> <string name="delete">Usuń</string> <string name="default_track">Domyślna ścieżka</string> - <string name="discovered_servers">Odnalezione Serwery</string> + <string name="discovered_servers">Odnalezione serwery</string> <string name="enter_server_url">Wprowadż IP serwera lub adres URL</string> <string name="file_size">Rozmiar</string> <string name="forced_track">Wymuszona ścieżka</string> @@ -100,8 +99,8 @@ <string name="name">Nazwa</string> <string name="remove_favorite">Usuń z ulubionych</string> <string name="restart">Uruchom ponownie</string> - <string name="select_server">Wybierz Serwer</string> - <string name="select_user">Wybierz Użytkownika</string> + <string name="select_server">Wybierz serwer</string> + <string name="select_user">Wybierz użytkownika</string> <string name="show_debug_info">Pokaż infomacje o debugowaniu</string> <string name="username_or_password">Użyj nazwy użytkownika/hasła</string> <string name="login">Login</string> @@ -115,34 +114,34 @@ <string name="remove">Usuń</string> <string name="live_tv">Telewizja</string> <string name="play_with_transcoding">Odtwórz z transkodowaniem</string> - <string name="show_clock">Pokaż Zegar</string> + <string name="show_clock">Pokaż zegar</string> <string name="font">Czcionka</string> <string name="background">Tło</string> <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łówki</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Czołówka</item> + <item quantity="few">Czołówki</item> + <item quantity="many">Czołówek</item> + <item quantity="other">Czołówek</item> </plurals> <string name="create_playlist">Utwórz nową playliste</string> <string name="add_to_playlist">Dodaj do playlisty</string> <string name="version">Wersja</string> <string name="updates">Aktualizacje</string> - <string name="cancel_recording">Anuluj Nagrywanie</string> + <string name="cancel_recording">Anuluj nagrywanie</string> <string name="view_options">Zobacz opcje</string> <string name="confirm_pin">Potwierdź PIN</string> <string name="incorrect">Nieprawidłowe</string> @@ -158,8 +157,8 @@ <string name="disable_if_crash">Wyłącz jeśli doświadczasz problemów</string> <string name="mpv_options">Opcje MPV</string> <string name="exoplayer_options">Opcje odtwarzacza ExoPlayer</string> - <string name="skip_segment_unknown">Pomiń Segment</string> - <string name="skip_segment_commercial">Pomiń Reklamy</string> + <string name="skip_segment_unknown">Pomiń segmenty</string> + <string name="skip_segment_commercial">Pomiń reklamy</string> <string name="skip_segment_intro">Pomiń czołówkę</string> <string name="skip_segment_outro">Pomiń napisy końcowe</string> <string name="sign_in_auto">Loguj automatycznie</string> @@ -176,15 +175,15 @@ <string name="font_color">Kolor czcionki</string> <string name="settings">Ustawienia</string> <string name="send_crash_reports">Wyślij raport o błędach</string> - <string name="update_available">Aktualizacja dostępna</string> + <string name="update_available">Dostępna aktualizacja</string> <string name="mpv_hardware_decoding">MPV: używaj dekodowania sprzętowego</string> - <string name="filter">Filtr</string> + <string name="filter">Filtruj</string> <string name="italic_font">Czcionka kursywy</string> <plurals name="behind_the_scenes"> <item quantity="one">Za kulisami</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="few">Za kulisami</item> + <item quantity="many">Za kulisami</item> + <item quantity="other">Za kulisami</item> </plurals> <string name="advanced_settings">Ustawienia zaawansowane</string> <string name="check_for_updates">Sprawdź dostępność aktualizacji</string> @@ -195,14 +194,14 @@ <string name="edge_size">Rozmiar ramki</string> <string name="about">O Wholphin</string> <string name="active_recordings">Nagrania w toku</string> - <string name="born">Data Urodzenia</string> - <string name="cancel_series_recording">Anuluj Nagrywanie Serialu</string> + <string name="born">Data urodzenia</string> + <string name="cancel_series_recording">Anuluj nagrywanie serialu</string> <string name="collection">Kolekcja</string> <string name="collections">Kolekcje</string> <string name="commercial">Reklama</string> - <string name="decimal_seconds">%.1f sekundy</string> + <string name="decimal_seconds">%.2f sekundy</string> <string name="disabled">Nieaktywne</string> - <string name="download_and_update"><![CDATA[Download & Update]]></string> + <string name="download_and_update"><![CDATA[Pobierz i zaktualizuj]]></string> <string name="directed_by">Wyreżyserowane przez %1$s</string> <string name="enabled">Aktywne</string> <string name="error_loading_collection">Błąd podczas ładowania kolekcji %1$s</string> @@ -215,9 +214,9 @@ <item quantity="other">%d godzin</item> </plurals> <string name="image_type">Typ obrazu</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> + <string name="cast_and_crew"><![CDATA[Obsada i ekipa]]></string> <string name="global_content_scale">Domyślne skalowanie</string> - <string name="direct_play_ass">Odtwarzaj bezpośrednio napisy ASS</string> + <string name="direct_play_ass">Używaj libass do napisów ASS</string> <string name="direct_play_pgs">Odtwarzaj bezpośrednio napisy PGS</string> <string name="downmix_stereo">Zawsze miksuj w dół do stereo</string> <string name="app_theme">Motyw interfejsu</string> @@ -236,7 +235,7 @@ <string name="auto_play_next_delay">Opóźnienie przed odtworzeniem kolejnego odcinka</string> <string name="auto_play_next">Automatycznie odtwarzaj kolejny odcinek</string> <string name="combine_continue_next_summary">Stosowane jedynie wobec seriali</string> - <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="combine_continue_next"><![CDATA[Połącz Kontynuuj oglądanie i Następne]]></string> <string name="max_homepage_items">Maksimum elementów wiersza na stronie domowej</string> <string name="nav_drawer_pins_summary">Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte</string> <plurals name="items"> @@ -253,7 +252,7 @@ <string name="playlist">Playlista</string> <string name="recap">Streszczenie</string> <string name="record_series">Zapisz serial</string> - <string name="search_and_download"><![CDATA[Search & Download]]></string> + <string name="search_and_download"><![CDATA[Znajdź i pobierz]]></string> <string name="show">Pokaż</string> <string name="subtitle_download_too_long">Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza</string> <string name="tv_dvr_schedule">Harmonogram nagrywania</string> @@ -261,24 +260,23 @@ <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="none">Żaden</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="official_rating">Ocena rodzicielska</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ęte sceny</item> - <item quantity="few"/> - <item quantity="many"/> - <item quantity="other"/> + <item quantity="one">Usunięta scena</item> + <item quantity="few">Usunięte sceny</item> + <item quantity="many">Usunięte sceny</item> + <item quantity="other">Usunięte sceny</item> </plurals> <string name="nav_drawer_switch_on_focus_summary_off">Naciśnij by zmienić stronę</string> <string name="send_app_logs_summary">Przydatne do debugowania</string> @@ -298,4 +296,513 @@ <string name="skip_back_on_resume_preference">Cofnij gdy wznawiasz odtwarzanie</string> <string name="player_backend">Player odtwarzający</string> <string name="pass_out_protection">Ochrona przed uśpieniem</string> + <string name="ends_at">Kończy się o %1$s</string> + <string name="enter_server_address">Wprowadź adres serwera</string> + <string name="immediate">Natychmiastowo</string> + <string name="no_servers_found">Nie znaleziono serwerów</string> + <string name="only_forced_subtitles">Tylko wymuszone napisy</string> + <string name="voice_search">Wyszukiwanie głosowe</string> + <string name="voice_starting">Uruchamianie</string> + <string name="voice_search_prompt">Mów żeby wyszukać</string> + <string name="processing">Przetwarzanie</string> + <string name="press_back_to_cancel">Wciśnij wstecz aby anulować</string> + <string name="voice_error_audio">Błąd nagrywania dźwięku</string> + <string name="voice_error_client">Błąd rozpoznawania mowy</string> + <string name="voice_error_permissions">Wymagane uprawnienia mikrofonu</string> + <string name="voice_error_network">Błąd sieci</string> + <string name="voice_error_server">Błąd serwera</string> + <string name="retry">Ponów</string> + <string name="video">Wideo</string> + <string name="videos">Widea</string> + <string name="watch_live">Oglądaj na żywo</string> + <string name="quick_connect_success">Urządzenie uwierzytelniono pomyślnie</string> + <string name="password">Hasło</string> + <string name="username">Nazwa użytkownika</string> + <string name="url">URL</string> + <string name="trending">Popularne</string> + <string name="upcoming_movies">Nadchodzące filmy</string> + <string name="upcoming_tv">Nadchodzące seriale</string> + <string name="request_4k">Poproś o 4K</string> + <string name="software_decoding_av1">AV1 dekodowanie programowe</string> + <string name="upgrade_mpv_toast">MPV jest teraz domyślnym odtwarzaczem, z wyjątkiem HDR.\nMożesz to zmienić w ustawieniach.</string> + <string name="hdr_subtitle_style">Styl napisów HDR</string> + <string name="rotate_left">Obróć w lewo</string> + <string name="rotate_right">Obróć w prawo</string> + <string name="zoom_in">Powiększ</string> + <string name="zoom_out">Pomniejsz</string> + <string name="send_media_info_log_to_server">Wyślij dane dziennika mediów do serwera</string> + <string name="no_limit">Bez limitu</string> + <string name="max_days_next_up">Maksymalna ilość dni w \"Do obejrzenia\"</string> + <string name="albums">Albumy</string> + <string name="artists">Artyści</string> + <string name="songs">Utwory</string> + <string name="go_to_artist">Przejdź do artysty</string> + <string name="now_playing">Teraz odtwarzane</string> + <string name="go_to_album">Przejdź do albumu</string> + <string name="add_to_queue">Dodaj do kolejki</string> + <string name="add_row">Dodaj wiersz</string> + <string name="height">Wysokość</string> + <string name="apply_all_rows">Zastosuj do wszystkich wierszy</string> + <string name="customize_home">Personalizacja strony głównej</string> + <string name="load_from_server">Wczytaj profil użytkownika z serwera</string> + <string name="save_to_server">Zapisz do profilu użytkownika na serwerze</string> + <string name="load_from_web_client">Wczytaj z klienta webowego</string> + <string name="voice_error_network_timeout">Przekroczono limit czasu odpowiedzi</string> + <string name="voice_error_no_match">Nie rozpoznano mowy</string> + <string name="general">Ogólne</string> + <string name="container">Kontener</string> + <string name="title">Tytuł</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Poziom</string> + <string name="resolution">Rozdzielczość</string> + <string name="anamorphic">Anamorficzny</string> + <string name="yes">Tak</string> + <string name="no">Nie</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">Pokaż tytuły</string> + <string name="live_tv_repeat">Powtarzaj</string> + <string name="favorite_channels_at_beginning">Pokaż ulubione kanały na początku</string> + <string name="sort_channels_recently_watched">Sortuj kanały według ostatnio oglądanych</string> + <string name="avc">AVC</string> + <string name="language">Język</string> + <string name="layout">Układ</string> + <string name="channels">Kanały</string> + <string name="screensaver">Wygaszacz ekranu</string> + <string name="screensaver_settings">Ustawienia wygaszacza ekranu</string> + <string name="start_screensaver">Pokaż wygaszacz ekranu</string> + <string name="duration">Czas trwania</string> + <string name="photos">Zdjęcia</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 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> + <string name="volume_low">Niska</string> + <string name="volume_medium">Średnia</string> + <string name="volume_high">Wysoka</string> + <string name="volume_full">Najwyższa</string> + <string name="purple">Fioletowy</string> + <string name="orange">Pomarańczowy</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> + <string name="skip_ask">Spytaj o pominięcie</string> + <string name="ffmpeg_fallback">Używaj FFmpeg tylko wtedy, gdy nie istnieje wbudowany dekoder</string> + <string name="ffmpeg_prefer">Używaj FFmpeg zamiast wbudowanych dekoderów</string> + <string name="ffmpeg_never">Nigdy nie używaj dekodera FFmpeg</string> + <string name="next_up_playback_end">Na końcu odtwarzania</string> + <string name="next_up_outro">Podczas napisów końcowych</string> + <string name="white">Biały</string> + <string name="light_gray">Jasno szary</string> + <string name="dark_gray">Ciemno szary</string> + <string name="yellow">Żółty</string> + <string name="cyan">Cyjan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_shadow">Cień</string> + <string name="subtitle_edge_outline">Ramka</string> + <string name="background_style_wrap">Zawijaj</string> + <string name="background_style_boxed">Opakowane</string> + <string name="prefer_mpv">Preferuj MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Użyj ExoPlayera do odtwarzania 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">Kwadrat (1:1)</string> + <string name="image_type_primary">Plakat</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Zdjęcie z dynamicznym kolorem</string> + <string name="backdrop_style_image">Tylko zdjęcie</string> + <string name="api_key">Klucz API</string> + <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_title">Ulubione %s</string> + <plurals name="days"> + <item quantity="one">%s dzień</item> + <item quantity="few">%s dni</item> + <item quantity="many">%s dni</item> + <item quantity="other">%s dni</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="few">%s minuty</item> + <item quantity="many">%s minut</item> + <item quantity="other">%s minut</item> + </plurals> + <string name="play_trailer">Odtwórz zwiastun</string> + <string name="no_trailers">Brak zwiastunów</string> + <string name="clear_track_choices">Wyczyść wybór utworów</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">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">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> + <string name="seerr_server_added">Serwer Seerr dodany</string> + <string name="quick_connect">Szybkie łączenie</string> + <string name="quick_connect_summary">Autoryzuj inne urządzenie do logowania się na Twoje konto</string> + <string name="quick_connect_code">Wprowadź kod szybkiego łączenia</string> + <string name="quick_connect_code_error">Kod musi mieć 6 cyfr</string> + <string name="image_subtitle_opacity">Przezroczystość napisów na obrazie</string> + <string name="voice_error_busy">Rozpoznawanie głosu w toku</string> + <string name="voice_error_speech_timeout">Nie wykryto mowy</string> + <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 tematyczna</item> + <item quantity="few">Piosenki tematyczne</item> + <item quantity="many">Piosenek tematycznych</item> + <item quantity="other">Piosenek tematycznych</item> + </plurals> + <string name="nav_drawer_switch_on_focus">Przełącz fokus na szufladę nawigacyjną</string> + <string name="playback_overrides">Nadpisywanie odtwarzania</string> + <string name="skip_commercials_behavior">Pomiń reklamy</string> + <string name="skip_intro_behavior">Pomiń czołówkę</string> + <string name="skip_outro_behavior">Pomiń napisy końcowe</string> + <string name="skip_previews_behavior">Pomiń zapowiedzi</string> + <string name="skip_recap_behavior">Pomiń streszczenia</string> + <string name="verbose_logging">Logi szczegółowe</string> + <string name="will_remove_pin">Usunie PIN</string> + <string name="video_range">Zakres wideo</string> + <string name="video_range_type">Typ zakresu wideo</string> + <string name="color_transfer">Transfer kolorów</string> + <string name="color_primaries">Kolory podstawowe</string> + <string name="pixel_format">Format piksela</string> + <string name="ref_frames">Ramki referencyjne</string> + <string name="nal">NAL</string> + <string name="sample_rate">Częstotliwość próbkowania</string> + <string name="color_code_programs">Programy z kodami kolorystycznymi</string> + <string name="subtitle_delay">Opóźnienie napisów</string> + <string name="backdrop_display">Styl tła</string> + <string name="resolution_switching">Zmień rozdzielczość</string> + <string name="local">Lokalny</string> + <string name="brightness">Jasność</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Nasycenie</string> + <string name="hue">Odcień</string> + <string name="red">Czerwony</string> + <string name="green">Zielony</string> + <string name="blue">Niebieski</string> + <string name="blur">Rozmazanie</string> + <string name="save_for_album">Zapisz do albumu</string> + <string name="play_slideshow">Odtwórz pokaz slajdów</string> + <string name="stop_slideshow">Zatrzymaj pokaz slajdów</string> + <string name="slideshow_at_beginning">Na początku</string> + <string name="no_more_images">Nie ma więcej zdjęć</string> + <string name="slideshow_duration">Czas trwania pokazu slajdów</string> + <string name="play_videos_during_slideshow">Odtwarzaj filmy podczas pokazu slajdów</string> + <string name="instant_mix">Mieszanka</string> + <string name="genres_in">Gatunki w %1$s</string> + <string name="recently_released_in">Ostatnio wydane w %1$s</string> + <string name="home_rows">Rzędy na stronie głównej</string> + <string name="use_series">Użyj obrazu serialu</string> + <string name="add_row_for">Dodaj wiersz do: %1$s</string> + <string name="overwrite_server_settings">Nadpisać ustawienia na serwerze?</string> + <string name="overwrite_local_settings">Nadpisać ustawienia lokalne?</string> + <string name="for_episodes">Do odcinków</string> + <string name="suggestions_for">Sugestie dla %1$s</string> + <string name="increase_all_cards_size">Zwiększ rozmiar wszystkich kart</string> + <string name="decrease_all_cards_size">Zmniejsz rozmiar wszystkich kart</string> + <string name="use_thumb_images">Użyj obrazów miniatur</string> + <string name="settings_saved">Ustawienia zapisane</string> + <string name="display_presets">Wyświetl ustawienia wstępne</string> + <string name="display_presets_description">Wbudowane ustawienia wstępne umożliwiające szybką stylizację wszystkich rzędów</string> + <string name="customize_home_summary">Wybierz rzędy i obrazy na stronie głównej</string> + <string name="start_after">Zacznij po</string> + <string name="animate">Animuj</string> + <string name="max_age_rating">Maksymalna ocena wiekowa</string> + <string name="no_max">Bez ograniczeń</string> + <string name="for_all_ages">Dla wszystkich grup wiekowych</string> + <string name="up_to_age">Do %s lat</string> + <string name="enter_url_api_key"><![CDATA[Wprowadź adres URL i klucz API]]></string> + <string name="search_and_download_subtitles"><![CDATA[Wyszukaj i pobierz napisy]]></string> + <string name="no_subtitles_found">Nie znaleziono napisów na serwerze</string> + <string name="series_continueing">Teraz</string> + <string name="in_app_screensaver">Użyj wygaszacza ekranu z aplikacji</string> + <string name="delete_item">Czy na pewno chcesz usunąć ten element?</string> + <string name="show_media_management">Pokaż opcje zarządzania multimediami</string> + <string name="search_for">Wyszukaj %s</string> + <string name="actor">Aktor</string> + <string name="composer">Kompozytor</string> + <string name="writer">Scenarzysta</string> + <string name="guest_star">Gwiazda gościnna</string> + <string name="producer">Producent</string> + <string name="conductor">Kierownik</string> + <string name="lyricist">Autor tekstów</string> + <string name="arranger">Aranżer</string> + <string name="engineer">Inżynier dźwięku</string> + <string name="mixer">Mikser</string> + <string name="creator">Twórca</string> + <string name="artist">Artysta</string> + <string name="album_artist">Artysta albumu</string> + <string name="album">Album</string> + <string name="popular_songs">Popularne piosenki</string> + <string name="play_next">Odtwarzaj dalej</string> + <string name="music_videos">Teledyski</string> + <string name="lyrics">Tekst</string> + <string name="hide_lyrics">Ukryj tekst</string> + <string name="show_lyrics">Pokaż tekst</string> + <string name="song_has_lyrics">Piosenka ma tekst</string> + <string name="remove_from_queue">Usuń z kolejki</string> + <string name="show_album_cover">Pokaż okładkę albumu</string> + <string name="show_visualizer">Pokaż wizualizator</string> + <string name="show_backdrop">Pokaż tło</string> + <string name="play_as_type">Odtwórz jako?</string> + <string name="visualizer_rationale">Aby móc wizualizować aktualnie odtwarzany dźwięk, wymagane jest pozwolenie na nagrywanie dźwięku.</string> + <string name="continue_string">Kontynuuj</string> + <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">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> + <string name="view_more">Zobacz więcej</string> + <string name="discover_tv">Odkryj programy TV</string> + <string name="studios_in">Studio w %1$s</string> + <string name="voice_error_unknown">Nieznany błąd</string> + <string name="media_information">Informacje o mediach</string> + <string name="aired_episode_order">Kolejność emisji odcinków</string> + <string name="runtime_sort">Czas trwania</string> + <plurals name="clips"> + <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ó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">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ó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> + <string name="refresh_rate_switching">Przełączenie częstotliwości odświeżania</string> + <string name="automatic">Automatycznie</string> + <string name="bit_depth">Głębia bitowa</string> + <string name="color_space">Przestrzeń kolorów</string> + <string name="interlaced">Przeplatany</string> + <string name="framerate">Liczba klatek</string> + <string name="discover_movies">Odkrywaj filmy</string> + <string name="next_up_never">Nigdy</string> + <string name="external_player">Zewnętrzny odtwarzacz</string> + <string name="last_played">Ostatnio odtwarzane</string> + <string name="system_default">Domyślny systemowy</string> + <string name="ass_subtitle_mode_libass">Bezpośrednie odtwarzanie z libass</string> + <string name="ass_subtitle_mode_exoplayer">Bezpośrednie odtwarzanie z wbudowanym ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Wypalaj napisy/transkoduj źródło na serwerze</string> + <string name="ass_subtitle_playback">Odtwarzanie napisów SSA/ASS</string> + <string name="cinema_mode">Tryb kinowy</string> + <string name="show_more">Pokaż %1$s więcej</string> + <string name="skip_behavior">Zachowanie pomijania</string> + <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 new file mode 100644 index 00000000..af1e0d9f --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,720 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Sobre</string> + <string name="active_recordings">Gravações ativas</string> + <string name="add_favorite">Favoritar</string> + <string name="add_server">Adicionar Servidor</string> + <string name="add_user">Adicionar Usuário</string> + <string name="audio">Áudio</string> + <string name="birthplace">Local de nascimento</string> + <string name="bitrate">Taxa de bits</string> + <string name="born">Nascido</string> + <string name="cancel_recording">Cancelar gravação</string> + <string name="cancel_series_recording">Cancelar gravação da série</string> + <string name="cancel">Cancelar</string> + <string name="chapters">Capítulos</string> + <string name="choose_stream">Escolha %1$s</string> + <string name="clear_image_cache">Limpar cache de imagens</string> + <string name="collection">Coleção</string> + <string name="collections">Coleções</string> + <string name="commercial">Comerciais</string> + <string name="community_rating">Avaliação da comunidade</string> + <string name="compose_error_message_title">Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor.</string> + <string name="confirm">Confirmar</string> + <string name="continue_watching">Continue assistindo</string> + <string name="critic_rating">Avaliação dos críticos</string> + <string name="decimal_seconds">%.2f segundos</string> + <string name="default_track">Padrão</string> + <string name="delete">Remover</string> + <string name="died">Falecido</string> + <string name="directed_by">Dirigido por %1$s</string> + <string name="director">Diretor</string> + <string name="disabled">Desabilitado</string> + <string name="discovered_servers">Servidores encontrados</string> + <string name="downloading">Baixando…</string> + <string name="enabled">Habilitado</string> + <string name="ends_at">Termina às %1$s</string> + <string name="enter_server_url">Insira o IP ou URL do Servidor</string> + <string name="enter_server_address">Insira o endereço do servidor</string> + <string name="episodes">Episódios</string> + <string name="error_loading_collection">Erro ao carregar a coleção %1$s</string> + <string name="external_track">Externo</string> + <string name="favorites">Favoritos</string> + <string name="file_size">Tamanho</string> + <string name="forced_track">Forçado</string> + <string name="genres">Gêneros</string> + <string name="go_to_series">Ir para séries</string> + <string name="go_to">Ir Para</string> + <string name="hide_controller_timeout">Ocultar controles de reprodução</string> + <string name="hide_debug_info">Ocultar informações de depuração</string> + <string name="hide">Ocultar</string> + <string name="home">Início</string> + <string name="immediate">Imediato</string> + <string name="intro">Introdução</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteca</string> + <string name="license_info">Informação de licenças</string> + <string name="live_tv">TV ao Vivo</string> + <string name="loading">Carregando…</string> + <string name="mark_entire_series_as_played">Marcar série inteira como reproduzida?</string> + <string name="mark_entire_series_as_unplayed">Marcar séria inteira como não reproduzida?</string> + <string name="mark_unwatched">Marcar como não assistida</string> + <string name="mark_watched">Marcar como assistida</string> + <string name="max_bitrate">Taxa de bits máxima</string> + <string name="more_like_this">Mais como isso</string> + <string name="more">Mais</string> + <plurals name="movies"> + <item quantity="one">Filme</item> + <item quantity="many">Filmes</item> + <item quantity="other">Filmes</item> + </plurals> + <string name="name">Nome</string> + <string name="next_up">A Seguir</string> + <string name="no_data">Sem dados</string> + <string name="no_results">Sem resultados</string> + <string name="no_servers_found">Nenhum servidor encontrado</string> + <string name="no_scheduled_recordings">Nenhuma gravação agendada</string> + <string name="no_update_available">Nenhuma atualização disponível</string> + <string name="none">Nenhum(a)</string> + <string name="only_forced_subtitles">Apenas Legendas Forçadas</string> + <string name="outro">Finalização</string> + <string name="path">Caminho</string> + <plurals name="people"> + <item quantity="one">Pessoa</item> + <item quantity="many">Pessoas</item> + <item quantity="other">Pessoas</item> + </plurals> + <string name="play_count">Contagem de Reproduções</string> + <string name="play_from_here">Reproduzir a partir daqui</string> + <string name="play">Reproduzir</string> + <string name="playback_debug_info">Mostrar informações de depuração da reprodução</string> + <string name="playback_speed">Velocidade de Reprodução</string> + <string name="playback">Reprodução</string> + <string name="playlist">Lista de reprodução</string> + <string name="playlists">Listas de reprodução</string> + <string name="preview">Prévia</string> + <string name="profile_specific_settings">Configurações do Perfil de Usuário</string> + <string name="queue">Fila</string> + <string name="recap">Recapitulação</string> + <string name="recently_added_in">Recentemente adicionado em %1$s</string> + <string name="recently_added">Recentemente adicionado</string> + <string name="recently_recorded">Recentemente Gravado</string> + <string name="recently_released">Recentemente Publicado</string> + <string name="recommended">Recomendado</string> + <string name="record_program">Gravar Programa</string> + <string name="record_series">Gravar Série</string> + <string name="remove_favorite">Desfavoritar</string> + <string name="restart">Reiniciar</string> + <string name="resume">Continuar</string> + <string name="save">Salvar</string> + <string name="search">Procurar</string> + <string name="searching">Procurando…</string> + <string name="voice_search">Pesquisa por voz</string> + <string name="voice_starting">Iniciando</string> + <string name="voice_search_prompt">Fale para pesquisar</string> + <string name="processing">Processando</string> + <string name="press_back_to_cancel">Aperte voltar para cancelar</string> + <string name="voice_error_audio">Erro de gravação de áudio</string> + <string name="voice_error_client">Erro de reconhecimento de voz</string> + <string name="voice_error_permissions">Permissão de microfone necessária</string> + <string name="voice_error_network">Erro de rede</string> + <string name="voice_error_network_timeout">Tempo limite de rede esgotado</string> + <string name="voice_error_no_match">Nenhuma fala reconhecida</string> + <string name="voice_error_busy">Reconhecimento de voz ocupado</string> + <string name="voice_error_server">Erro de servidor</string> + <string name="voice_error_speech_timeout">Nenhuma fala detectada</string> + <string name="voice_error_unknown">Erro desconhecido</string> + <string name="voice_error_start_failed">Falha no início do reconhecimento</string> + <string name="voice_error_timeout">Tempo limite do reconhecimento de voz esgotado</string> + <string name="retry">Tentar novamente</string> + <string name="select_server">Selecionar Servidor</string> + <string name="select_user">Selecionar Usuário</string> + <string name="show_debug_info">Mostrar informações de depuração</string> + <string name="show_next_up_when">Mostrar próximo</string> + <string name="show">Mostrar</string> + <string name="shuffle">Aleatório</string> + <string name="skip">Pular</string> + <string name="sort_by_date_added">Data de Adição</string> + <string name="sort_by_date_episode_added">Data de Adição do Episódio</string> + <string name="sort_by_date_played">Data de Reprodução</string> + <string name="sort_by_date_released">Data de Lançamento</string> + <string name="sort_by_name">Nome</string> + <string name="sort_by_random">Aleatório</string> + <string name="studios">Estúdios</string> + <string name="submit">Enviar</string> + <string name="subtitle_download_too_long">O download está demorando muito, talvez você tenha que reiniciar a reprodução</string> + <string name="subtitle">Legenda</string> + <string name="subtitles">Legendas</string> + <string name="suggestions">Sugestões</string> + <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="tv_dvr_schedule">Agenda do DVR</string> + <string name="tv_guide">Guia</string> + <string name="tv_season">Temporada</string> + <string name="tv_seasons">Temporadas</string> + <plurals name="tv_shows"> + <item quantity="one">Programa de TV</item> + <item quantity="many">Programas de TV</item> + <item quantity="other">Programas de TV</item> + </plurals> + <string name="ui_interface">Interface</string> + <string name="unknown">Desconhecido</string> + <string name="updates">Atualizações</string> + <string name="version">Versão</string> + <string name="video_scale">Escala de Vídeo</string> + <string name="video">Vídeo</string> + <string name="videos">Vídeos</string> + <string name="watch_live">Assista ao vivo</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> + <string name="aired_episode_order">Ordem de Lançamento do Episódio</string> + <string name="create_playlist">Criar nova lista de reprodução</string> + <string name="add_to_playlist">Adicionar a lista de reprodução</string> + <string name="one_click_pause">Pausar com um clique</string> + <string name="one_click_pause_summary_on">Aperte o centro do controle direcional para pausar/reproduzir</string> + <string name="italic_font">Colocar a fonte em itálico</string> + <string name="font">Fonte</string> + <string name="background">Plano de fundo</string> + <string name="success">Sucesso</string> + <string name="official_rating">Classificação Parental</string> + <string name="runtime_sort">Duração</string> + <string name="extras_title">Extras</string> + <plurals name="other_extras"> + <item quantity="one">Outro</item> + <item quantity="many">Outros</item> + <item quantity="other">Outros</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Por Trás das Cenas</item> + <item quantity="many">Por Trás das Cenas</item> + <item quantity="other">Por Trás das Cenas</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Música Tema</item> + <item quantity="many">Músicas Tema</item> + <item quantity="other">Músicas Tema</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Vídeo Tema</item> + <item quantity="many">Vídeos Tema</item> + <item quantity="other">Vídeos Tema</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Clipe</item> + <item quantity="many">Clipes</item> + <item quantity="other">Clipes</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Cena Deletada</item> + <item quantity="many">Cenas Deletadas</item> + <item quantity="other">Cenas Deletadas</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Entrevista</item> + <item quantity="many">Entrevistas</item> + <item quantity="other">Entrevistas</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Cena</item> + <item quantity="many">Cenas</item> + <item quantity="other">Cenas</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Amostra</item> + <item quantity="many">Amostras</item> + <item quantity="other">Amostras</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Curta</item> + <item quantity="many">Curtas</item> + <item quantity="other">Curtas</item> + </plurals> + <string name="favorite_items_title">Favoritar %s</string> + <plurals name="downloads"> + <item quantity="one">%s download</item> + <item quantity="many">%s downloads</item> + <item quantity="other">%s downloads</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hora</item> + <item quantity="many">%d horas</item> + <item quantity="other">%d horas</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dia</item> + <item quantity="many">%s dias</item> + <item quantity="other">%s dias</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d item</item> + <item quantity="many">%d itens</item> + <item quantity="other">%d itens</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d segundo</item> + <item quantity="many">%d segundos</item> + <item quantity="other">%d segundos</item> + </plurals> + <string name="ac3_supported">Dispositivo suporta AC3/Dolby Digital</string> + <string name="advanced_settings">Configurações Avançadas</string> + <string name="advanced_ui">IU Avançada</string> + <string name="app_theme">Tema do Aplicativo</string> + <string name="auto_check_for_updates">Checar atualizações automaticamente</string> + <string name="auto_play_next_delay">Atraso antes da próxima reprodução</string> + <string name="auto_play_next">Reproduzir próximo automaticamente</string> + <string name="check_for_updates">Checar atualizações</string> + <string name="combine_continue_next_summary">Aplicar apenas a Séries de TV</string> + <string name="direct_play_ass">Usar libass para reproduzir legendas ASS</string> + <string name="direct_play_pgs">Reproduzir legendas PGS diretamente</string> + <string name="downmix_stereo">Sempre mixar para estéreo</string> + <string name="ffmpeg_extension_pref">Usar módulo de decodificação do FFmpeg</string> + <string name="global_content_scale">Escala padrão do conteúdo</string> + <string name="install_update">Instalar atualização</string> + <string name="installed_version">Versão instalada</string> + <string name="max_homepage_items">Máximo de itens nas linhas da página inicial</string> + <string name="nav_drawer_pins_summary">Escolher itens exibidos por padrão, os outros serão ocultados</string> + <string name="nav_drawer_pins">Personalizar os Itens da Gaveta de Navegação</string> + <string name="nav_drawer_switch_on_focus_summary_off">Clicar para trocar de páginas</string> + <string name="nav_drawer_switch_on_focus">Trocar a página de navegação no foco</string> + <string name="pass_out_protection">Proteção Contra Desmaios</string> + <string name="play_theme_music">Tocar música tema</string> + <string name="playback_overrides">Sobreposições de reprodução</string> + <string name="remember_selected_tab">Lembrar abas selecionadas</string> + <string name="rewatch_next_up">Habilitar reassistir em a seguir</string> + <string name="seek_bar_steps">Passos da barra de busca</string> + <string name="send_app_logs_summary">Útil para depuração</string> + <string name="send_app_logs">Enviar os registros do aplicativo para o servidor atual</string> + <string name="send_crash_reports_summary">Tentativa de envio para o último servidor conectado</string> + <string name="send_crash_reports">Enviar Relatórios de Falhas</string> + <string name="settings">Configurações</string> + <string name="skip_commercials_behavior">Comportamento de pular os comerciais</string> + <string name="skip_intro_behavior">Comportamento de pular as introduções</string> + <string name="skip_outro_behavior">Comportamento de pular a finalização</string> + <string name="skip_previews_behavior">Comportamento de pular as prévias</string> + <string name="skip_recap_behavior">Comportamento de pular as recapitulações</string> + <string name="update_available">Atualização disponível</string> + <string name="update_url_summary">URL usada para verificação de atualizações</string> + <string name="update_url">URL de atualização</string> + <string name="font_size">Tamanho da fonte</string> + <string name="font_color">Cor da fonte</string> + <string name="font_opacity">Opacidade da fonte</string> + <string name="edge_style">Estilo da borda</string> + <string name="edge_color">Cor da borda</string> + <string name="background_opacity">Opacidade do fundo</string> + <string name="background_style">Estilo do fundo</string> + <string name="subtitle_style">Estilo da legenda</string> + <string name="background_color">Cor do fundo</string> + <string name="reset">Redefinir</string> + <string name="bold_font">Fonte em negrito</string> + <string name="player_backend">Backend de reprodução</string> + <string name="mpv_hardware_decoding">MPV: Usar decodificação baseada em hardware</string> + <string name="disable_if_crash">Desabilite se encontrar falhas</string> + <string name="mpv_options">Opções do MPV</string> + <string name="exoplayer_options">Opções do ExPlayer</string> + <string name="skip_segment_unknown">Pular Segmento</string> + <string name="skip_segment_commercial">Pular Anúncios</string> + <string name="skip_segment_preview">Pular Prévia</string> + <string name="skip_segment_recap">Pular Recapitulação</string> + <string name="skip_segment_outro">Pular Finalização</string> + <string name="skip_segment_intro">Pular Introdução</string> + <string name="played">Reproduzido</string> + <string name="filter">Filtro</string> + <string name="year">Ano</string> + <string name="decade">Década</string> + <string name="remove">Remover</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> + <string name="mpv_conf">Editar mpv.conf</string> + <string name="discard_change">Descartar mudanças?</string> + <string name="subtitle_margin">Margem</string> + <string name="verbose_logging">Registro extenso</string> + <string name="enter_pin">Insira o PIN</string> + <string name="sign_in_auto">Entrar automaticamente</string> + <string name="use_server_credentials">Autenticar via servidor</string> + <string name="press_enter_to_confirm">Aperte o botão central para confirmar</string> + <string name="require_pin_code">Exigir PIN para o perfil</string> + <string name="confirm_pin">Confirmar o PIN</string> + <string name="incorrect">Incorreto</string> + <string name="pin_too_short">PIN deve ter 4 ou mais dígitos</string> + <string name="will_remove_pin">Irá remover o PIN</string> + <string name="image_cache_size">Tamanho do cache de imagens no disco (MB)</string> + <string name="view_options">Ver opções</string> + <string name="columns">Colunas</string> + <string name="spacing">Espaçamento</string> + <string name="aspect_ratio">Proporção da Tela</string> + <string name="show_details">Mostrar detalhes</string> + <string name="image_type">Tipo de imagem</string> + <string name="guest_stars">Estrelas convidadas</string> + <string name="edge_size">Tamanho da borda</string> + <string name="refresh_rate_switching">Troca de taxa de atualização</string> + <string name="automatic">Automático</string> + <string name="username_or_password">Usar nome de usuário / senha</string> + <string name="login">Autenticação</string> + <string name="general">Geral</string> + <string name="container">Contêiner</string> + <string name="title">Título</string> + <string name="codec">Codec</string> + <string name="profile">Perfil</string> + <string name="level">Nível</string> + <string name="resolution">Resolução</string> + <string name="anamorphic">Anamórfico</string> + <string name="interlaced">Entrelaçado</string> + <string name="framerate">Taxa de atualização</string> + <string name="bit_depth">Profundidade de bits</string> + <string name="video_range">Alcance de vídeo</string> + <string name="video_range_type">Tipo de alcance de vídeo</string> + <string name="color_space">Espaço de cores</string> + <string name="color_transfer">Transferência de cores</string> + <string name="color_primaries">Primárias de cores</string> + <string name="pixel_format">Formato de pixel</string> + <string name="ref_frames">Quadros de referência</string> + <string name="language">Idioma</string> + <string name="layout">Layout</string> + <string name="channels">Canais</string> + <string name="sample_rate">Taxa de amostra</string> + <string name="yes">Sim</string> + <string name="no">Não</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">Mostrar títulos</string> + <string name="live_tv_repeat">Repetir</string> + <string name="favorite_channels_at_beginning">Mostrar canais favoritos primeiro</string> + <string name="sort_channels_recently_watched">Ordenar canais por recentemente assistidos</string> + <string name="color_code_programs">Codificar programas por cor</string> + <string name="subtitle_delay">Atraso na legenda</string> + <string name="backdrop_display">Estilo de fundo</string> + <string name="resolution_switching">Troca de resolução</string> + <string name="local">Local</string> + <string name="play_trailer">Reproduzir trailer</string> + <string name="no_trailers">Nenhum trailer</string> + <string name="clear_track_choices">Limpar escolhas de faixas</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">Reprodução direta de Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorar checagens de compatibilidade de dispositivo</string> + <string name="discover">Descobrir</string> + <string name="request">Solicitar</string> + <string name="pending">Pendente</string> + <string name="seerr_integration">Integração com o Seerr</string> + <string name="remove_seerr_server">Remover Servidor Seerr</string> + <string name="seerr_server_added">Servidor Seerr adicionado</string> + <string name="quick_connect">Conexão Rápida</string> + <string name="quick_connect_summary">Autorizar outro dispositivo a se autenticar com sua conta</string> + <string name="quick_connect_code">Insira o código de conexão rápida</string> + <string name="quick_connect_code_error">O código deve ter 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> + <string name="password">Senha</string> + <string name="username">Nome de usuário</string> + <string name="url">URL</string> + <string name="trending">Tendências</string> + <string name="upcoming_movies">Filmes Lançados em Breve</string> + <string name="upcoming_tv">Programas de TV Lançados em Breve</string> + <string name="request_4k">Solicitar em 4K</string> + <string name="software_decoding_av1">Decodificação de AV1 via software</string> + <string name="upgrade_mpv_toast">MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações.</string> + <string name="hdr_subtitle_style">Estilo da legenda HDR</string> + <string name="image_subtitle_opacity">Opacidade da legenda de imagem</string> + <string name="brightness">Brilho</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturação</string> + <string name="hue">Matiz</string> + <string name="red">Vermelho</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desfoque</string> + <string name="play_slideshow">Reproduzir apresentação de slides</string> + <string name="stop_slideshow">Interromper apresentação de slides</string> + <string name="slideshow_at_beginning">No começo</string> + <string name="no_more_images">Sem mais fotos</string> + <string name="rotate_left">Girar a esquerda</string> + <string name="rotate_right">Girar a direita</string> + <string name="zoom_in">Mais zoom</string> + <string name="zoom_out">Menos zoom</string> + <string name="slideshow_duration">Duração da reprodução de slides</string> + <string name="play_videos_during_slideshow">Reproduzir vídeos ao longo da apresentação de slides</string> + <string name="send_media_info_log_to_server">Enviar registro de informações da mídia ao servidor</string> + <string name="no_limit">Sem limite</string> + <string name="max_days_next_up">Máximo de dias no A Seguir</string> + <string name="add_row">Adicionar linha</string> + <string name="genres_in">Gêneros em %1$s</string> + <string name="recently_released_in">Recentemente lançado em %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas as linhas</string> + <string name="customize_home">Personalizar página inicial</string> + <string name="skip_back_on_resume_preference">Pular para trás ao resumir a reprodução</string> + <string name="skip_back_preference">Pular para trás</string> + <string name="skip_forward_preference">Pular para frente</string> + <string name="nal">NAL</string> + <string name="avc">AVC</string> + <string name="save_for_album">Salvar para álbum</string> + <string name="home_rows">Linhas iniciais</string> + <string name="load_from_server">Carregar a partir do perfil de usuário no servidor</string> + <string name="save_to_server">Salvar para o perfil de usuário no servidor</string> + <string name="load_from_web_client">Carregar a partir do cliente web</string> + <string name="use_series">Usar imagem da série</string> + <string name="add_row_for">Adicionar linha para %1$s</string> + <string name="overwrite_server_settings">Sobrescrever configurações no servidor?</string> + <string name="overwrite_local_settings">Sobrescrever configurações locais?</string> + <string name="for_episodes">Para episódios</string> + <string name="suggestions_for">Sugestões para %1$s</string> + <string name="increase_all_cards_size">Aumentar tamanho para todos os cartões</string> + <string name="decrease_all_cards_size">Diminuir tamanho para todos os cartões</string> + <string name="use_thumb_images">Usar imagens de prévia</string> + <string name="settings_saved">Configurações salvas</string> + <string name="display_presets">Predefinições de exibição</string> + <string name="display_presets_description">Predefinições embutidas para estilizar rapidamente todas as linhas</string> + <string name="customize_home_summary">Escolher linhas e imagens na página inicial</string> + <string name="start_after">Iniciar após</string> + <string name="animate">Animar</string> + <string name="max_age_rating">Classificação etária máxima</string> + <string name="no_max">Sem máximo</string> + <string name="for_all_ages">Para todas as idades</string> + <string name="up_to_age">Até a idade %s</string> + <string name="screensaver">Protetor de tela</string> + <string name="screensaver_settings">Configurações do protetor de tela</string> + <string name="start_screensaver">Iniciar o protetor de tela</string> + <string name="duration">Duração</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Para quais tipos de itens mostrar imagens</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Cortar</string> + <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 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> + <string name="volume_low">Baixo</string> + <string name="volume_medium">Médio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Roxo</string> + <string name="orange">Laranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Preto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Pular automaticamente</string> + <string name="skip_ask">Perguntar se pular</string> + <string name="ffmpeg_fallback">Apenas usar FFmpeg se não existir nenhum decodificador embarcado</string> + <string name="ffmpeg_prefer">Preferir usar FFmpeg aos decodificadores embarcados</string> + <string name="ffmpeg_never">Nunca usar decodificadores FFmpeg</string> + <string name="next_up_playback_end">Ao fim da reprodução</string> + <string name="next_up_outro">Durante os créditos/finalização</string> + <string name="white">Branco</string> + <string name="light_gray">Cinza claro</string> + <string name="dark_gray">Cinza escuro</string> + <string name="yellow">Amarelo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Envolver</string> + <string name="background_style_boxed">Encaixotar</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</string> + <string name="aspect_ratios_poster">Pôster (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">Quadrado (1:1)</string> + <string name="image_type_primary">Primária</string> + <string name="image_type_thumb">Prévia</string> + <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> + <string name="backdrop_style_image">Apenas imagem</string> + <string name="api_key">Chave de API</string> + <string name="seerr_login">Autenticar no servidor Seerr</string> + <string name="seerr_jellyfin_user">Usuário Jellyfin</string> + <string name="seerr_local_user">Usuário local</string> + <string name="no_subtitles_found">Nenhuma legenda remota encontrada</string> + <string name="series_continueing">Presente</string> + <string name="in_app_screensaver">Usar protetor de tela do aplicativo</string> + <string name="delete_item">Tem certeza que quer apagar este item?</string> + <string name="show_media_management">Mostrar opções de gerenciamento de mídia</string> + <string name="actor">Ator</string> + <string name="composer">Compositor</string> + <string name="writer">Escritor</string> + <string name="guest_star">Estrela convidada</string> + <string name="producer">Produtor</string> + <string name="conductor">Regente</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arranjador</string> + <string name="engineer">Engenheiro</string> + <string name="creator">Criador</string> + <string name="artist">Artista</string> + <string name="search_for">Buscar %s</string> + <string name="mixer">Mixagem</string> + <plurals name="featurettes"> + <item quantity="one">Bastidores</item> + <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">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> + <string name="go_to_artist">Ir para artista</string> + <string name="now_playing">Reproduzindo agora</string> + <string name="instant_mix">Mix instantâneo</string> + <string name="go_to_album">Ir para álbum</string> + <string name="add_to_queue">Adicionar à fila</string> + <string name="next_up_never">Nunca</string> + <string name="external_player">Reprodutor externo</string> + <string name="album_artist">Artista do álbum</string> + <string name="album">Álbum</string> + <string name="popular_songs">Músicas populares</string> + <string name="play_next">Reproduzir a seguir</string> + <string name="music_videos">Vídeoclipes</string> + <string name="lyrics">Letras</string> + <string name="hide_lyrics">Ocultar letras</string> + <string name="show_lyrics">Mostrar letras</string> + <string name="song_has_lyrics">Música contém letras</string> + <string name="remove_from_queue">Remover da fila</string> + <string name="show_album_cover">Mostrar capa do álbum</string> + <string name="show_visualizer">Mostrar visualizador</string> + <string name="show_backdrop">Mostrar fundo</string> + <string name="play_as_type">Reproduzir como?</string> + <string name="visualizer_rationale">Visualizar o áudio atualmente reproduzido requer as permissões de gravação de áudio.</string> + <string name="continue_string">Continuar</string> + <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">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> + <string name="view_more">Ver mais</string> + <string name="discover_tv">Descobrir programas de TV</string> + <string name="discover_movies">Descobrir Filmes</string> + <string name="studios_in">Estúdios em %1$s</string> + <string name="last_played">Reproduzido por último</string> + <string name="system_default">Padrão do sistema</string> + <string name="ass_subtitle_mode_libass">Reprodução direta com libass</string> + <string name="ass_subtitle_mode_exoplayer">Reprodução direta com ExoPlayer embarcado</string> + <string name="ass_subtitle_mode_transcode">Gravar/transcodificar no servidor</string> + <string name="ass_subtitle_playback">Reprodução de legenda SSA/ASS</string> + <string name="cinema_mode">Modo cinema</string> + <string name="show_more">Mostrar mais %1$s</string> + <string name="skip_behavior">Comportamento de pulo</string> + <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 c2ee4aec..f65e53af 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">O Wholphin</string> + <string name="about">Sobre</string> <string name="add_favorite">Favorito</string> <string name="add_server">Adicionar Servidor</string> <string name="add_user">Adicionar Utilizador</string> @@ -10,8 +10,8 @@ <string name="chapters">Capítulos</string> <string name="choose_stream">Escolher %1$s</string> <string name="clear_image_cache">Eliminar cache de imagens</string> - <string name="collection">Colecção</string> - <string name="collections">Colecções</string> + <string name="collection">Coleção</string> + <string name="collections">Coleções</string> <string name="confirm">Confirmar</string> <string name="decimal_seconds">%.2f segundos</string> <string name="default_track">Padrão</string> @@ -27,7 +27,7 @@ <string name="file_size">Tamanho</string> <string name="forced_track">Forçado</string> <string name="go_to">Ir para</string> - <string name="active_recordings">Gravações Activas</string> + <string name="active_recordings">Gravações Ativas</string> <string name="birthplace">Local de Nascimento</string> <string name="bitrate">Bitrate</string> <string name="born">Nascido</string> @@ -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,9 +119,8 @@ <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">Trailers</item> + <item quantity="one">Trailer</item> <item quantity="many">Trailers</item> <item quantity="other">Trailers</item> </plurals> @@ -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> @@ -240,7 +243,7 @@ <string name="check_for_updates">Verificar se há atualizações</string> <string name="combine_continue_next_summary">Aplica-se apenas a Séries de TV</string> <string name="combine_continue_next"><![CDATA[Combinar Continuar a Ver e A Seguir]]></string> - <string name="direct_play_ass">Reprodução direta de legendas ASS</string> + <string name="direct_play_ass">Utilizar libass para legendas ASS</string> <string name="direct_play_pgs">Reprodução direta de legendas PGS</string> <string name="downmix_stereo">Conversão sempre em estéreo</string> <string name="ffmpeg_extension_pref">Usar o módulo decodificador FFmpeg</string> @@ -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> @@ -382,6 +385,7 @@ <string name="clear_track_choices">Apagar seleções de faixas</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> @@ -451,4 +455,330 @@ <string name="send_media_info_log_to_server">Enviar registo de informações de mídia para o servidor</string> <string name="no_limit">Sem limite</string> <string name="max_days_next_up">Dias máximos em \'A Seguir\'</string> + <string name="quick_connect">Ligação Rápida</string> + <string name="quick_connect_summary">Autorize outro dispositivo a iniciar sessão na sua conta</string> + <string name="quick_connect_code">Insira o código de Ligação Rápida</string> + <string name="quick_connect_code_error">Código deve ter 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> + <string name="add_row">Adicionar linha</string> + <string name="genres_in">Géneros em %1$s</string> + <string name="recently_released_in">Lançado recentemente em %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas as linhas</string> + <string name="customize_home">Personalizar página inicial</string> + <string name="home_rows">Linhas da página inicial</string> + <string name="load_from_server">Carregar do perfil de utilizador do servidor</string> + <string name="save_to_server">Guardar no perfil de utilizador do servidor</string> + <string name="load_from_web_client">Carregar a partir do cliente web</string> + <string name="use_series">Usar imagem da série</string> + <string name="add_row_for">Adicionar linha para %1$s</string> + <string name="overwrite_server_settings">Substituir as definições no servidor?</string> + <string name="overwrite_local_settings">Substituir as definições locais?</string> + <string name="for_episodes">Por episódios</string> + <string name="suggestions_for">Sugestões para %1$s</string> + <string name="increase_all_cards_size">Aumentar o tamanho de todos os cartões</string> + <string name="use_thumb_images">Usar imagens de prévia</string> + <string name="settings_saved">Configurações salvas</string> + <string name="display_presets">Mostrar pré definições</string> + <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_title">Favoritos %s</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Começar depois</string> + <string name="animate">Animado</string> + <string name="max_age_rating">Idade máxima</string> + <string name="no_max">Sem máximo</string> + <string name="for_all_ages">Para todas as idades</string> + <string name="up_to_age">Até a idade %s</string> + <string name="screensaver">Proteção de ecrã</string> + <string name="screensaver_settings">Definições de protecção de ecrã</string> + <string name="start_screensaver">Iniciar protecção de ecrã</string> + <string name="duration">Duração</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Que tipos de itens devem mostrar imagens</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Recortar</string> + <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 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> + <string name="volume_low">Baixo</string> + <string name="volume_medium">Médio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Roxo</string> + <string name="orange">Laranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Preto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Saltar automaticamente</string> + <string name="skip_ask">Pedir para saltar</string> + <string name="ffmpeg_fallback">Usa FFmpeg apenas se não houver um descodificador integrado</string> + <string name="ffmpeg_prefer">Prefere o FFmpeg aos descodificadores integrados</string> + <string name="ffmpeg_never">Nunca usar os descodificadores FFmpeg</string> + <string name="next_up_playback_end">No final da reprodução</string> + <string name="next_up_outro">Durante os créditos finais</string> + <string name="white">Branco</string> + <string name="light_gray">Cinzento claro</string> + <string name="dark_gray">Cinzento escuro</string> + <string name="yellow">Amarelo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Envolver</string> + <string name="background_style_boxed">Com caixa</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</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">Quadrado (1:1)</string> + <string name="image_type_primary">Primária</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> + <string name="backdrop_style_image">Apenas imagem</string> + <string name="enter_url_api_key"><![CDATA[Inserir URL e Chave API]]></string> + <string name="api_key">Chave API</string> + <string name="seerr_login">Iniciar sessão no servidor Seerr</string> + <string name="seerr_jellyfin_user">Utilizador Jellyfin</string> + <string name="seerr_local_user">Utilizador local</string> + <string name="search_and_download_subtitles"><![CDATA[Procurar e transferir legendas]]></string> + <string name="no_subtitles_found">Não foram encontradas legendas remotas</string> + <string name="series_continueing">Em curso</string> + <string name="in_app_screensaver">Usar protetor de ecrã da aplicação</string> + <string name="actor">Ator</string> + <string name="composer">Compositor</string> + <string name="writer">Argumentista</string> + <string name="guest_star">Convidado especial</string> + <string name="producer">Produtor</string> + <string name="conductor">Director musicar</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arranjador</string> + <string name="engineer">Engenheiro de som</string> + <string name="mixer">Técnico de mixagem</string> + <string name="creator">Criador</string> + <string name="artist">Artista</string> + <string name="delete_item">Tem a certeza de que deseja eliminar este elemento?</string> + <string name="show_media_management">Mostrar opções de gestão de multimédia</string> + <string name="search_for">Pesquisar %s</string> + <string name="select_all">Selecionar tudo</string> + <string name="albums">Álbuns</string> + <string name="artists">Artistas</string> + <string name="songs">Músicas</string> + <string name="go_to_artist">Ir para o artista</string> + <string name="now_playing">A reproduzir</string> + <string name="instant_mix">Mistura instantânea</string> + <string name="go_to_album">Ir para o álbum</string> + <string name="add_to_queue">Adicionar à fila</string> + <string name="album_artist">Artista do álbum</string> + <string name="album">Álbum</string> + <string name="popular_songs">Mais ouvidas</string> + <string name="play_next">Reproduzir a seguir</string> + <string name="music_videos">Videoclips</string> + <string name="lyrics">Letras</string> + <string name="hide_lyrics">Esconder letras</string> + <string name="show_lyrics">Mostrar letras</string> + <string name="song_has_lyrics">Música contêm letras</string> + <string name="remove_from_queue">Remover da fila</string> + <string name="show_album_cover">Mostrar capa do álbum</string> + <string name="show_visualizer">Mostrar visualizador</string> + <string name="show_backdrop">Mostrar imagem de fundo</string> + <string name="play_as_type">Reproduzir como?</string> + <string name="visualizer_rationale">Para visualizar o áudio que está a ser reproduzido, é necessária autorização para gravar áudio.</string> + <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">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> + <string name="view_more">Ver mais</string> + <string name="discover_tv">Descobrir Séries</string> + <string name="discover_movies">Descobrir Filmes</string> + <string name="studios_in">Estúdios em %1$s</string> + <string name="next_up_never">Nunca</string> + <string name="external_player">Reprodutor Externo</string> + <string name="last_played">Última reprodução</string> + <string name="system_default">Padrão do sistema</string> + <string name="ass_subtitle_mode_libass">Reprodução directa com libass</string> + <string name="ass_subtitle_mode_exoplayer">Reprodução directa com ExoPlayer integrado</string> + <string name="ass_subtitle_mode_transcode">Incorporar/transcodificar no servidor</string> + <string name="ass_subtitle_playback">Reprodução de legendas SSA/ASS</string> + <string name="cinema_mode">Modo Cinema</string> + <string name="show_more">Mostrar %1$s mais</string> + <string name="skip_behavior">Comportamento de saltar</string> + <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 new file mode 100644 index 00000000..d6a2d917 --- /dev/null +++ b/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,392 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Despre</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Adaugă server</string> + <string name="add_user">Adaugă utilizator</string> + <string name="audio">Audio</string> + <string name="birthplace">Locul nașterii</string> + <string name="born">Născut</string> + <string name="cancel_recording">Anulează înregistrarea</string> + <string name="cancel">Anulează</string> + <string name="chapters">Capitole</string> + <string name="choose_stream">Alege %1$s</string> + <string name="collection">Colecție</string> + <string name="collections">Colecții</string> + <string name="downloading">Se descarcă…</string> + <string name="directed_by">Regizat de %1$s</string> + <string name="clear_image_cache">Golește memoria cache a imaginilor</string> + <string name="confirm">Confirmă</string> + <string name="director">Regizor</string> + <string name="favorites">Favorite</string> + <string name="ends_at">Se încheie la %1$s</string> + <string name="enter_server_url">Introdu adresa IP sau URL-ul</string> + <string name="enter_server_address">Introdu adresa serverului</string> + <string name="episodes">Episoade</string> + <string name="error_loading_collection">Eroare la încărcarea colecției %1$s</string> + <string name="external_track">Extern</string> + <string name="file_size">Dimensiune</string> + <string name="enabled">Activat</string> + <string name="delete">Șterge</string> + <string name="continue_watching">Continuă vizionarea</string> + <string name="decimal_seconds">%.2f secunde</string> + <string name="disabled">Dezactivat</string> + <string name="genres">Genuri</string> + <string name="intro">Introducere</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="name">Nume</string> + <string name="next_up">În continuare</string> + <string name="no_results">Nu există rezultate</string> + <string name="no_servers_found">Nu s-au găsit servere</string> + <string name="no_scheduled_recordings">Nu sunt înregistrări programate</string> + <string name="no_update_available">Nu este disponibilă nicio actualizare</string> + <plurals name="movies"> + <item quantity="one">Filme</item> + <item quantity="other">Filme</item> + </plurals> + <string name="library">Librărie</string> + <string name="go_to">Mergi la</string> + <string name="go_to_series">Mergi la seria</string> + <string name="loading">Se încarcă…</string> + <string name="mark_entire_series_as_played">Marchez întreaga serie ca vizionată?</string> + <string name="mark_entire_series_as_unplayed">Marchez întreaga serie ca nevizionată?</string> + <string name="mark_unwatched">Marchează ca nevizionat</string> + <string name="mark_watched">Marchează ca vizionat</string> + <plurals name="people"> + <item quantity="one">Persoane</item> + <item quantity="other">Persoane</item> + </plurals> + <string name="play_count">Număr de redări</string> + <string name="more">Mai mult</string> + <string name="home">Acasă</string> + <string name="no_data">Nu există date</string> + <string name="outro">Încheiere</string> + <string name="path">Cale</string> + <string name="more_like_this">Mai multe articole similare</string> + <string name="hide">Ascunde</string> + <string name="language">Limbă</string> + <string name="resolution">Rezoluție</string> + <string name="title">Titlu</string> + <string name="columns">Coloane</string> + <string name="confirm_pin">Confirmă PIN</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="enter_pin">Introdu PIN</string> + <string name="sign_in_auto">Conectare automată</string> + <string name="pin_too_short">PIN-ul trebuie sa aibă 4 sau mai multe caractere</string> + <string name="discard_change">Anulezi modificările?</string> + <string name="year">An</string> + <string name="decade">Decadă</string> + <string name="filter">Filtru</string> + <string name="played">Redat</string> + <string name="mpv_options">Opțiuni MPV</string> + <string name="exoplayer_options">Opțiuni ExoPlayer</string> + <string name="mpv_conf">Modifică mpv.conf</string> + <string name="spacing">Spațiere</string> + <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">Distribuție și echipă</string> + <string name="guest_stars">Vedete invitate</string> + <string name="level">Nivel</string> + <string name="channels">Canale</string> + <string name="yes">Da</string> + <string name="no">Nu</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="show_titles">Arată titlurile</string> + <string name="live_tv_repeat">Repetă</string> + <string name="favorite_channels_at_beginning">Afișează mai întâi canalele favorite</string> + <string name="sort_channels_recently_watched">Sortează canalele după vizionarea recentă</string> + <string name="subtitle_delay">Decalare subitrare</string> + <string name="discover">Descoperă</string> + <string name="play_trailer">Redă trailer</string> + <string name="no_trailers">Fără trailere</string> + <string name="seerr_integration">Integrare Seerr</string> + <string name="remove_seerr_server">Elimină serverul Seerr</string> + <string name="seerr_server_added">Serverul Seer adăugat</string> + <string name="quick_connect">Conectare rapidă</string> + <string name="quick_connect_summary">Autorizează un alt dispozitiv să se conecteze la contul tău</string> + <string name="quick_connect_code">Introdu codul de conectare rapidă</string> + <string name="quick_connect_code_error">Codul trebuie să aibă 6 cifre</string> + <string name="quick_connect_success">Dispozitiv autorizat cu success</string> + <string name="password">Parolă</string> + <string name="username">Nume utilizator</string> + <string name="url">Adresă URL</string> + <string name="trending">În tendințe</string> + <string name="upcoming_movies">Filme viitoare</string> + <string name="upcoming_tv">Seriale viitoare</string> + <string name="request_4k">Solicită în format 4K</string> + <string name="brightness">Luminozitate</string> + <string name="contrast">Contrast</string> + <string name="saturation">Saturație</string> + <string name="hue">Nuanță</string> + <string name="red">Roșu</string> + <string name="green">Verde</string> + <string name="blue">Albastru</string> + <string name="blur">Estompare</string> + <string name="save_for_album">Salvează pentru album</string> + <string name="albums">Albume</string> + <string name="artists">Artiști</string> + <string name="songs">Cântece</string> + <string name="go_to_artist">Mergi la artist</string> + <string name="now_playing">Se redă acum</string> + <string name="instant_mix">Mix instant</string> + <string name="go_to_album">Mergi la album</string> + <string name="add_to_queue">Adaugă în coadă</string> + <string name="add_row">Adaugă rând</string> + <string name="genres_in">Genuri în %1$s</string> + <string name="recently_released_in">Lansate recent în %1$s</string> + <string name="height">Înălțime</string> + <string name="apply_all_rows">Aplică la toate rândurile</string> + <string name="customize_home">Personalizează pagina de pornire</string> + <string name="for_episodes">Pentru episoade</string> + <string name="suggestions_for">Sugestii pentru %1$s</string> + <string name="increase_all_cards_size">Mărește dimensiunea tuturor cardurilor</string> + <string name="decrease_all_cards_size">Micșorează dimensiunea tuturor cardurilor</string> + <string name="settings_saved">Setări salvate</string> + <string name="duration">Durată</string> + <string name="photos">Fotografii</string> + <string name="volume_lowest">Cel mai scăzut</string> + <string name="volume_low">Scăzut</string> + <string name="volume_medium">Mediu</string> + <string name="volume_high">Ridicat</string> + <string name="volume_full">Maxim</string> + <string name="purple">Violet</string> + <string name="orange">Portocaliu</string> + <string name="white">Alb</string> + <string name="light_gray">Gri deschis</string> + <string name="dark_gray">Gri închis</string> + <string name="yellow">Galben</string> + <string name="cyan">Cian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contur</string> + <string name="subtitle_edge_shadow">Umbră</string> + <string name="prefer_mpv">Preferă MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Folosește ExoPlayer pentru redare HDR</string> + <string name="aspect_ratios_square">Pătrat (1:1)</string> + <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">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> + <string name="default_track">Implicit</string> + <string name="died">Decedat</string> + <string name="active_recordings">Înregistrări active</string> + <string name="critic_rating">Evaluarea criticilor</string> + <string name="hide_controller_timeout">Ascunde comenzile de redare</string> + <string name="live_tv">TV în direct</string> + <string name="play">Redă</string> + <string name="recap">Recapitulare</string> + <string name="recently_added">Adăugat recent</string> + <string name="recently_recorded">Înregistrat recent</string> + <string name="recently_released">Lansat recent</string> + <string name="recommended">Recomandat</string> + <string name="playlists">Liste de redare</string> + <string name="preview">Previzualizare</string> + <string name="profile_specific_settings">Setări profil utilizator</string> + <string name="queue">Coadă</string> + <string name="recently_added_in">Adăugat recent în %1$s</string> + <string name="search">Caută</string> + <string name="searching">Se caută…</string> + <string name="voice_search">Căutare vocală</string> + <string name="processing">Se procesează</string> + <string name="select_server">Alege server</string> + <string name="select_user">Alege utilizator</string> + <string name="skip">Sări peste</string> + <string name="retry">Reîncearcă</string> + <string name="show">Arată</string> + <string name="studios">Studiouri</string> + <string name="submit">Trimite</string> + <string name="subtitle_download_too_long">Descărcarea durează mult; s-ar putea să fie necesar să repornești redarea</string> + <string name="subtitle">Subtitrare</string> + <string name="subtitles">Subtitrări</string> + <string name="suggestions">Sugestii</string> + <string name="switch_servers">Schimbă serverele</string> + <string name="switch_user">Schimbă</string> + <string name="tv_season">Sezon</string> + <string name="tv_seasons">Sezoane</string> + <plurals name="tv_shows"> + <item quantity="one">Seriale TV</item> + <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> + <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="other">Interviuri</item> + </plurals> + <string name="background">Fundal</string> + <plurals name="downloads"> + <item quantity="one">%s descărcare</item> + <item quantity="few">%s descărcări</item> + <item quantity="other">%s descărcări</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d oră</item> + <item quantity="few">%d ore</item> + <item quantity="other">%d ore</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s zi</item> + <item quantity="few">%s zile</item> + <item quantity="other">%s zile</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d articol</item> + <item quantity="few">%d articole</item> + <item quantity="other">%d articole</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d secundă</item> + <item quantity="few">%d secunde</item> + <item quantity="other">%d secunde</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="few">%s minute</item> + <item quantity="other">%s minute</item> + </plurals> + <string name="check_for_updates">Verifică dacă sunt actualizări</string> + <string name="install_update">Instalează actualizarea</string> + <string name="installed_version">Versiune instalată</string> + <string name="settings">Setări</string> + <string name="font_size">Dimensiune font</string> + <string name="font_color">Culoare font</string> + <string name="font_opacity">Opacitate font</string> + <string name="edge_style">Stil margine</string> + <string name="background_color">Culoare fundal</string> + <string name="reset">Resetare</string> + <string name="update_available">Actualizare disponibilă</string> + <string name="update_url_summary">URL folosit pentru a căuta actualizări</string> + <string name="update_url">URL actualizare</string> + <string name="send_crash_reports">Trimite rapoarte de eroare</string> + <string name="subtitle_style">Stil subtitrare</string> + <string name="background_style">Stil fundal</string> + <string name="skip_segment_intro">Sări peste introducere</string> + <string name="skip_segment_commercial">Sări peste reclame</string> + <string name="skip_segment_preview">Sări peste previzualizare</string> + <string name="skip_segment_recap">Sări peste recapitulare</string> + <string name="incorrect">Incorect</string> + <string name="username_or_password">Folosește utilizator/parolă</string> + <string name="request">Solicitare</string> + <string name="pending">În așteptare</string> + <string name="upgrade_mpv_toast">MPV este acum playerul implicit, cu excepția conținutului HDR.\nPoți modifica aceasta din setări.</string> + <string name="hdr_subtitle_style">Stilul subtitrărilor HDR</string> + <string name="rotate_right">Rotește la dreapta</string> + <string name="for_all_ages">Pentru toate vârstele</string> + <string name="up_to_age">Până la vârsta de %s</string> + <string name="screensaver">Economizor de ecran</string> + <string name="screensaver_settings">Setări economizor de ecran</string> + <string name="skip_automatically">Sări automat</string> + <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">Caută & descarcă subtitrări</string> + <string name="composer">Compozitor</string> + <string name="writer">Scriitor</string> + <string name="lyrics">Versuri</string> + <string name="hide_lyrics">Ascunde versurile</string> + <string name="show_lyrics">Afișează versurile</string> + <string name="song_has_lyrics">Cântecul are versuri</string> + <string name="select_all">Selectează tot</string> + <string name="play_as_type">Redă ca?</string> + <string name="cancel_series_recording">Anulează înregistrarea seriei</string> + <string name="license_info">Informații privind licența</string> + <string name="forced_track">Forțat</string> + <string name="hide_debug_info">Ascunde informațiile de depanare</string> + <string name="immediate">Imediat</string> + <string name="none">Niciuna</string> + <string name="play_from_here">Redă de aici</string> + <string name="playback_speed">Viteza de redare</string> + <string name="playback">Redare</string> + <string name="playlist">Listă de redare</string> + <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">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> + <string name="restart">Repornire</string> + <string name="voice_error_network">Eroare rețea</string> + <string name="shuffle">Amestecă</string> + <string name="top_unwatched">Cele mai bine cotate, nevizionate</string> + <plurals name="trailers"> + <item quantity="one">Trailere</item> + <item quantity="other">Trailere</item> + </plurals> + <string name="play_with_transcoding">Redă cu transcodare</string> + <string name="media_information">Informații media</string> + <string name="create_playlist">Creează o listă noua de redare</string> + <string name="add_to_playlist">Adaugă în lista de redare</string> + <plurals name="other_extras"> + <item quantity="one">Altele</item> + <item quantity="other">Altele</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">În spatele scenelor</item> + <item quantity="other">În spatele scenelor</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Melodii tematice</item> + <item quantity="other">Melodii tematice</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Scene șterse</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_title">Extra</string> + <plurals name="clips"> + <item quantity="one">Clipuri</item> + <item quantity="other">Clipuri</item> + </plurals> + <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> + <string name="advanced_settings">Setări avansate</string> + <string name="advanced_ui">Interfață avansată</string> + <string name="auto_play_next">Redă automat următorul</string> + <string name="combine_continue_next_summary">Se aplică numai serialelor TV</string> + <string name="nav_drawer_switch_on_focus_summary_off">Faceți clic pentru a schimba paginile</string> + <string name="skip_segment_unknown">Sări peste segment</string> + <string name="skip_segment_outro">Sări peste final</string> + <string name="use_server_credentials">Autentificare prin server</string> + <string name="press_enter_to_confirm">Apăsați în centru pentru a confirma</string> + <string name="require_pin_code">Solicită codul PIN pentru profil</string> + <string name="view_options">Opțiuni de vizualizare</string> + <string name="login">Autentificare</string> + <string name="layout">Aspect</string> + <string name="rotate_left">Rotește la stânga</string> + <string name="overwrite_server_settings">Suprascrie setările pe server?</string> + <string name="overwrite_local_settings">Suprascrie setările locale?</string> + <string name="start_after">Începe după</string> + <string name="include_types_summary">Pentru ce tipuri de articole să se afișeze imagini</string> + <string name="black">Negru</string> + <string name="skip_ignore">Ignoră</string> + <string name="next_up_playback_end">La sfârșitul redării</string> + <string name="next_up_outro">În timpul genericului de final/outro</string> + <string name="bold_blue">Albastru aldin</string> + <string name="bold_font">Font aldin</string> + <string name="player_backend">Motorul de redare</string> + <string name="mpv_hardware_decoding">MPV: Utilizează decodarea hardware</string> + <string name="disable_if_crash">Dezactivează această opțiune dacă aplicația se blochează</string> + <string name="background_opacity">Opacitatea fundalului</string> + <string name="profile">Profil</string> +</resources> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 04204a19..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,48 +347,459 @@ <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> <string name="yes">Да</string> <string name="no">Нет</string> + <plurals name="days"> + <item quantity="one">%s день</item> + <item quantity="few">%s дня</item> + <item quantity="many">%s дней</item> + <item quantity="other">%s дней</item> + </plurals> + <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="channels">Каналы</string> + <string name="sample_rate">Частота дискретизации</string> + <string name="contrast">Контрастность</string> + <string name="saturation">Насыщенность</string> + <string name="hue">Оттенок</string> + <string name="red">Красный</string> + <string name="green">Зелёный</string> + <string name="blue">Синий</string> + <string name="brightness">Яркость</string> + <string name="seerr_integration">Интеграция с Seerr</string> + <string name="search_for">Найти %s</string> + <string name="select_all">Выбрать всё</string> + <string name="subtitle_delay">Задержка субтитров</string> + <string name="play_trailer">Воспроизвести трейлер</string> + <string name="no_trailers">Трейлеров нет</string> + <string name="duration">Длительность</string> + <string name="photos">Фотографии</string> + <string name="purple">Фиолетовый</string> + <string name="orange">Оранжевый</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> + <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 c55a7acc..50d2edd7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">O Wholphine</string> + <string name="about">O aplikácii</string> <string name="active_recordings">Aktívne nahrávky</string> <string name="add_favorite">Obľúbené</string> <string name="add_server">Pridať server</string> @@ -22,7 +22,7 @@ <string name="confirm">Potvrdiť</string> <string name="continue_watching">Pokračovať v pozeraní</string> <string name="critic_rating">Hodnotenie kritikov</string> - <string name="decimal_seconds">%.1f sekúnd</string> + <string name="decimal_seconds">%.2f sekúnd</string> <string name="default_track">Predvolené</string> <string name="delete">Vymazať</string> <string name="died">Zomrel</string> @@ -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> @@ -102,4 +102,681 @@ <string name="select_user">Vybrať používateľa</string> <string name="show_debug_info">Zobraziť informácie o debugu</string> <string name="compose_error_message_title">Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server.</string> + <string name="ends_at">Končí o %1$s</string> + <string name="enter_server_address">Zadajte adresu servera</string> + <string name="no_servers_found">Nenašli sa žiadne servery</string> + <string name="only_forced_subtitles">Iba vynútené titulky</string> + <string name="voice_search">Hlasové vyhľadávanie</string> + <string name="voice_starting">Spúšťanie</string> + <string name="voice_search_prompt">Hovorením vyhľadávajte</string> + <string name="processing">Spracovanie</string> + <string name="press_back_to_cancel">Stlačte späť pre zrušenie</string> + <string name="voice_error_audio">Chyba nahrávania zvuku</string> + <string name="voice_error_client">Chyba rozpoznávania hlasu</string> + <string name="voice_error_permissions">Vyžaduje sa povolenie na prístup k mikrofónu</string> + <string name="voice_error_network">Chyba siete</string> + <string name="voice_error_network_timeout">Časový limit siete</string> + <string name="voice_error_no_match">Nebol rozpoznaný žiadny hlas</string> + <string name="voice_error_busy">Rozpoznávanie hlasu je zaneprázdnené</string> + <string name="voice_error_server">Chyba servera</string> + <string name="voice_error_speech_timeout">Nebola zistená žiadna reč</string> + <string name="voice_error_unknown">Neznáma chyba</string> + <string name="voice_error_start_failed">Nepodarilo sa spustiť rozpoznávanie hlasu</string> + <string name="voice_error_timeout">Časový limit rozpoznávania hlasu vypršal</string> + <string name="retry">Skúsiť znova</string> + <string name="show_next_up_when">Zobraziť ďalšie</string> + <string name="show">Zobraziť</string> + <string name="shuffle">Náhodne</string> + <string name="skip">Preskočiť</string> + <string name="sort_by_date_added">Dátum pridania</string> + <string name="sort_by_date_episode_added">Dátum pridania epizódy</string> + <string name="sort_by_date_played">Dátum prehratia</string> + <string name="sort_by_date_released">Dátum vydania</string> + <string name="sort_by_name">Názov</string> + <string name="sort_by_random">Náhodne</string> + <string name="studios">Štúdiá</string> + <string name="submit">Odoslať</string> + <string name="subtitle_download_too_long">Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie</string> + <string name="subtitle">Podnázov</string> + <string name="subtitles">Titulky</string> + <string name="suggestions">Návrhy</string> + <string name="switch_servers">Zmeniť servery</string> + <string name="switch_user">Zmeniť</string> + <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> + <plurals name="trailers"> + <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á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> + <string name="updates">Aktualizácie</string> + <string name="version">Verzia</string> + <string name="video_scale">Mierka videa</string> + <string name="video">Video</string> + <string name="videos">Videá</string> + <string name="watch_live">Sledovať naživo</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> + <string name="aired_episode_order">Poradie odvysielaných epizód</string> + <string name="create_playlist">Vytvoriť nový playlist</string> + <string name="add_to_playlist">Pridať do playlistu</string> + <string name="one_click_pause">Pozastavenie jedným kliknutím</string> + <string name="one_click_pause_summary_on">Stlačte stred D-Padu pre pozastavenie/prehrávanie</string> + <string name="italic_font">Kurzívový font</string> + <string name="font">Font</string> + <string name="background">Pozadie</string> + <string name="success">Úspech</string> + <string name="official_rating">Rodičovské hodnotenie</string> + <string name="runtime_sort">Dĺžka</string> + <string name="extras_title">Bonusový materiál</string> + <plurals name="other_extras"> + <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">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á 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é 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">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é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">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é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">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áž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 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_title">Obľúbené %s</string> + <plurals name="downloads"> + <item quantity="one">%s stiahnutý</item> + <item quantity="few">%s stiahnuté</item> + <item quantity="many">%s stiahnutých</item> + <item quantity="other">%s stiahnutých</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hodina</item> + <item quantity="few">%d hodiny</item> + <item quantity="many">%d hodín</item> + <item quantity="other">%d hodín</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s deň</item> + <item quantity="few">%s dni</item> + <item quantity="many">%s dní</item> + <item quantity="other">%s dní</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d položka</item> + <item quantity="few">%d položky</item> + <item quantity="many">%d položiek</item> + <item quantity="other">%d položiek</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekundy</item> + <item quantity="many">%d sekúnd</item> + <item quantity="other">%d sekúnd</item> + </plurals> + <string name="ac3_supported">Zariadenie podporuje AC3/Dolby Digital</string> + <string name="advanced_settings">Pokročilé nastavenia</string> + <string name="advanced_ui">Pokročilé UI</string> + <string name="app_theme">Téma aplikácie</string> + <string name="auto_check_for_updates">Automaticky kontrolovať aktualizácie</string> + <string name="check_for_updates">Skontrolovať aktualizácie</string> + <string name="combine_continue_next_summary">Platí len pre seriály</string> + <string name="direct_play_ass">Použiť libass pre ASS titulky</string> + <string name="direct_play_pgs">Priame prehrávanie PGS titulkov</string> + <string name="downmix_stereo">Vždy downmixovať do stereo formátu</string> + <string name="ffmpeg_extension_pref">Použiť modul FFmpeg dekodéra</string> + <string name="global_content_scale">Predvolená mierka obsahu</string> + <string name="install_update">Nainštalovať aktualizáciu</string> + <string name="installed_version">Nainštalovaná verzia</string> + <string name="max_homepage_items">Maximálny počet položiek v riadkoch na domovskej obrazovke</string> + <string name="nav_drawer_pins_summary">Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté</string> + <string name="nav_drawer_pins">Prispôsobenie položiek navigačného panela</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknutím prepnete stránky</string> + <string name="nav_drawer_switch_on_focus">Prepnúť stránky navigačného panelu pri zaostrení</string> + <string name="pass_out_protection">Ochrana proti výpadku</string> + <string name="play_theme_music">Prehrať tématickú hudbu</string> + <string name="remember_selected_tab">Zapamätať vybrané karty</string> + <string name="seek_bar_steps">Kroky seek baru</string> + <string name="send_app_logs_summary">Užitočné pre debugovanie</string> + <string name="send_app_logs">Odoslať logy aplikácie na aktuálny server</string> + <string name="send_crash_reports_summary">Pokus o odoslanie na posledný pripojený server</string> + <string name="send_crash_reports">Odoslať správy o zlyhaní</string> + <string name="settings">Nastavenia</string> + <plurals name="minutes"> + <item quantity="one">%s minúta</item> + <item quantity="few">%s minúty</item> + <item quantity="many">%s minút</item> + <item quantity="other">%s minút</item> + </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[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> + <string name="skip_back_preference">Preskočiť späť</string> + <string name="skip_commercials_behavior">Správanie preskočenia reklamy</string> + <string name="skip_forward_preference">Preskočiť dopredu</string> + <string name="skip_intro_behavior">Správanie preskočenia intra</string> + <string name="skip_outro_behavior">Správanie preskočenia outra</string> + <string name="skip_previews_behavior">Správanie preskočenia náhľadov</string> + <string name="skip_recap_behavior">Správanie preskočenia rekapitulácie</string> + <string name="update_available">Aktualizácia k dispozícii</string> + <string name="update_url_summary">URL používaná na kontrolu aktualizácií aplikácie</string> + <string name="update_url">Aktualizovať URL</string> + <string name="font_size">Veľkosť písma</string> + <string name="font_color">Farba písma</string> + <string name="font_opacity">Priehľadnosť písma</string> + <string name="edge_style">Štýl okraja</string> + <string name="edge_color">Farba okraja</string> + <string name="background_opacity">Prehľadnosť pozadia</string> + <string name="background_style">Štýl pozadia</string> + <string name="subtitle_style">Štýl titulkov</string> + <string name="background_color">Farba pozadia</string> + <string name="reset">Reset</string> + <string name="bold_font">Tučné písmo</string> + <string name="player_backend">Backend prehrávania</string> + <string name="mpv_hardware_decoding">MPV: Používa hardvérové dekódovanie</string> + <string name="disable_if_crash">Zakážte, ak dochádza k pádom</string> + <string name="mpv_options">Možnosti MPV</string> + <string name="exoplayer_options">Možnosti ExoPlayer</string> + <string name="skip_segment_unknown">Preskočiť segment</string> + <string name="skip_segment_commercial">Preskočiť reklamy</string> + <string name="skip_segment_preview">Preskočiť náhľad</string> + <string name="skip_segment_recap">Preskočiť rekapituláciu</string> + <string name="skip_segment_outro">Preskočiť outro</string> + <string name="skip_segment_intro">Preskočiť intro</string> + <string name="played">Prehrané</string> + <string name="filter">Filter</string> + <string name="year">Rok</string> + <string name="decade">Desaťročie</string> + <string name="remove">Odstrániť</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Použiť gpu-next</string> + <string name="mpv_conf">Upraviť mpv.conf</string> + <string name="discard_change">Zrušiť zmeny?</string> + <string name="subtitle_margin">Okraj</string> + <string name="verbose_logging">Podrobné logovanie</string> + <string name="enter_pin">Zadajte PIN</string> + <string name="sign_in_auto">Automatické prihlásenie</string> + <string name="use_server_credentials">Prihlásenie cez server</string> + <string name="press_enter_to_confirm">Stlačte stredové tlačidlo na potvrdenie</string> + <string name="require_pin_code">Vyžadovať PIN pre profil</string> + <string name="confirm_pin">Potvrdiť PIN</string> + <string name="incorrect">Nesprávny</string> + <string name="pin_too_short">PIN musí mať minimálne 4 znaky</string> + <string name="will_remove_pin">Odstráni PIN</string> + <string name="image_cache_size">Veľkosť pamäte obrázkov (MB)</string> + <string name="view_options">Možnosti zobrazenia</string> + <string name="columns">Stĺpce</string> + <string name="spacing">Rozostup</string> + <string name="aspect_ratio">Pomer strán</string> + <string name="show_details">Zobraziť podrobnosti</string> + <string name="image_type">Typ obrázku</string> + <string name="cast_and_crew"><![CDATA[Obsadenie a štáb]]></string> + <string name="guest_stars">Hosťujúce hviezdy</string> + <string name="edge_size">Veľkosť okraja</string> + <string name="refresh_rate_switching">Prepínanie obnovovacej frekvencie</string> + <string name="automatic">Automaticky</string> + <string name="username_or_password">Použite používateľské meno/heslo</string> + <string name="login">Prihlásenie</string> + <string name="general">Všeobecné</string> + <string name="container">Kontajner</string> + <string name="title">Titul</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Úroveň</string> + <string name="resolution">Rozlíšenie</string> + <string name="anamorphic">Anamorfný</string> + <string name="interlaced">Prekladané</string> + <string name="framerate">Snímková frekvencia</string> + <string name="bit_depth">Bitová hĺbka</string> + <string name="video_range">Rozsah videa</string> + <string name="video_range_type">Typ rozsahu videa</string> + <string name="color_space">Farebný priestor</string> + <string name="color_transfer">Prenos farieb</string> + <string name="color_primaries">Základné farby</string> + <string name="pixel_format">Formát pixelov</string> + <string name="ref_frames">Referenčné snímky</string> + <string name="nal">NAL</string> + <string name="language">Jazyk</string> + <string name="layout">Rozloženie</string> + <string name="channels">Kanály</string> + <string name="sample_rate">Vzorkovacia frekvencia</string> + <string name="avc">AVC</string> + <string name="yes">Áno</string> + <string name="no">Nie</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">Zobraziť názvy</string> + <string name="live_tv_repeat">Opakovať</string> + <string name="favorite_channels_at_beginning">Zobraziť obľúbené kanály ako prvé</string> + <string name="sort_channels_recently_watched">Zoradiť kanály podľa nedávno pozretých</string> + <string name="color_code_programs">Farebné rozlíšenie programov</string> + <string name="subtitle_delay">Oneskorenie titulkov</string> + <string name="backdrop_display">Štýl backdropu</string> + <string name="resolution_switching">Prepínanie rozlíšenia</string> + <string name="local">Lokálne</string> + <string name="play_trailer">Prehrať trailer</string> + <string name="no_trailers">Žiadne trailery</string> + <string name="clear_track_choices">Vyčistiť výber stôp</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</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">Priame prehrávanie Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorovať kontrolu kompatibility zariadenia</string> + <string name="request">Žiadosť</string> + <string name="pending">Čaká</string> + <string name="seerr_integration">Integrácia Seerr</string> + <string name="remove_seerr_server">Odstrániť Seerr server</string> + <string name="seerr_server_added">Seerr server pridaný</string> + <string name="quick_connect">Rýchle pripojenie</string> + <string name="writer">Scenár</string> + <string name="guest_star">Hosťujúca hviezda</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Textár</string> + <string name="arranger">Aranžér</string> + <string name="engineer">Inžinier</string> + <string name="mixer">Zvukový mixér</string> + <string name="creator">Tvorca</string> + <string name="artist">Umelec</string> + <string name="search_for">Hľadať %s</string> + <string name="quick_connect_code">Zadajte kód Rýchleho pripojenia</string> + <string name="quick_connect_code_error">Kód musí mať 6 číslic</string> + <string name="quick_connect_success">Zariadenie bolo úspešne autorizované</string> + <string name="password">Heslo</string> + <string name="username">Používateľské meno</string> + <string name="url">URL</string> + <string name="trending">Trendujúce</string> + <string name="upcoming_movies">Nadchádzajúce filmy</string> + <string name="upcoming_tv">Nadchádzajúce seriály</string> + <string name="request_4k">Vyžiadať v 4K</string> + <string name="software_decoding_av1">Softvérové dekódovanie AV1</string> + <string name="upgrade_mpv_toast">MPV je teraz predvolený prehrávač okrem HDR.\nToto môžete zmeniť v nastaveniach.</string> + <string name="hdr_subtitle_style">Štýl HDR titulkov</string> + <string name="image_subtitle_opacity">Priehľadnosť obrázkových titulkov</string> + <string name="brightness">Jas</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Sýtosť</string> + <string name="hue">Odtieň</string> + <string name="red">Červená</string> + <string name="green">Zelená</string> + <string name="blue">Modrá</string> + <string name="blur">Rozmazanie</string> + <string name="save_for_album">Uložiť do albumu</string> + <string name="play_slideshow">Prehrať prezentáciu</string> + <string name="stop_slideshow">Zastaviť prezentáciu</string> + <string name="slideshow_at_beginning">Na začiatok</string> + <string name="no_more_images">Žiadne ďalšie fotky</string> + <string name="rotate_left">Otočiť doľava</string> + <string name="rotate_right">Otočiť doprava</string> + <string name="zoom_in">Zväčšiť</string> + <string name="zoom_out">Zmenšiť</string> + <string name="slideshow_duration">Trvanie prezentácie</string> + <string name="play_videos_during_slideshow">Prehrávať videá počas prezentácie</string> + <string name="no_limit">Bez obmedzenia</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="overwrite_server_settings">Prepísať nastavenia na serveri?</string> + <string name="overwrite_local_settings">Prepísať lokálne nastavenia?</string> + <string name="for_episodes">Pre epizódy</string> + <string name="suggestions_for">Návrhy pre %1$s</string> + <string name="increase_all_cards_size">Zväčšiť veľkosť všetkých kariet</string> + <string name="decrease_all_cards_size">Zmenšiť veľkosť všetkých kariet</string> + <string name="use_thumb_images">Použiť náhľadové obrázky</string> + <string name="settings_saved">Nastavenia uložené</string> + <string name="display_presets">Predvoľby displeja</string> + <string name="display_presets_description">Vstavané predvoľby na rýchlu úpravu všetkých riadkov</string> + <string name="customize_home_summary">Vyberte riadky a obrázky na domovskej stránke</string> + <string name="start_after">Začať po</string> + <string name="animate">Animovať</string> + <string name="max_age_rating">Maximálne vekové hodnotenie</string> + <string name="no_max">Bez vekového obmedzenia</string> + <string name="for_all_ages">Pre všetky vekové kategórie</string> + <string name="up_to_age">Do veku %s</string> + <string name="screensaver">Šetrič obrazovky</string> + <string name="screensaver_settings">Nastavenia šetriča obrazovky</string> + <string name="start_screensaver">Spustiť šetrič obrazovky</string> + <string name="duration">Trvanie</string> + <string name="photos">Fotografie</string> + <string name="include_types">Zahrnúť typy</string> + <string name="include_types_summary">Pre ktoré typy položiek zobrazovať obrázky</string> + <string name="content_scale_fit">Prispôsobiť</string> + <string name="content_scale_crop">Orezať</string> + <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">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> + <string name="add_row">Pridať riadok</string> + <string name="genres_in">Žánre v %1$s</string> + <string name="recently_released_in">Nedávno vydané v %1$s</string> + <string name="height">Výška</string> + <string name="apply_all_rows">Použiť na všetky riadky</string> + <string name="customize_home">Prispôsobiť domovskú stránku</string> + <string name="home_rows">Riadky na domovskej stránke</string> + <string name="load_from_server">Načítať zo serverového profilu používateľa</string> + <string name="save_to_server">Uložiť do serverového profilu používateľa</string> + <string name="load_from_web_client">Načítať z webového klienta</string> + <string name="use_series">Použiť obrázok seriálu</string> + <string name="add_row_for">Pridať riadok pre %1$s</string> + <string name="volume_lowest">Najnižšia</string> + <string name="volume_low">Nízka</string> + <string name="volume_medium">Stredná</string> + <string name="volume_high">Vysoká</string> + <string name="volume_full">Plná</string> + <string name="purple">Fialová</string> + <string name="orange">Oranžová</string> + <string name="bold_blue">Výrazná modrá</string> + <string name="black">Čierna</string> + <string name="skip_ignore">Ignorovať</string> + <string name="skip_automatically">Preskočiť automaticky</string> + <string name="skip_ask">Opýtať sa na preskočenie</string> + <string name="ffmpeg_fallback">Použiť FFmpeg len v prípade, ak neexistuje žiadny vstavaný dekodér</string> + <string name="ffmpeg_prefer">Preferovať použitie FFmpeg pred vstavanými dekodérmi</string> + <string name="ffmpeg_never">Nikdy nepoužívať FFmpeg dekodéry</string> + <string name="next_up_playback_end">Na konci prehrávania</string> + <string name="next_up_outro">Počas záverečných titulkov/outro</string> + <string name="white">Biela</string> + <string name="light_gray">Svetlo šedá</string> + <string name="dark_gray">Tmavo šedá</string> + <string name="yellow">Žltá</string> + <string name="cyan">Azúrová</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Obrys</string> + <string name="subtitle_edge_shadow">Tieň</string> + <string name="background_style_wrap">Prispôsobiť pozadie textu</string> + <string name="background_style_boxed">Text v rámčeku</string> + <string name="prefer_mpv">Uprednostniť MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Použiť ExoPlayer na prehrávanie HDR</string> + <string name="aspect_ratios_poster">Plagát (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">Štvorec (1:1)</string> + <string name="image_type_primary">Primárny</string> + <string name="backdrop_style_dynamic">Obrázok s dynamickou farbou</string> + <string name="backdrop_style_image">Iba obrázok</string> + <string name="enter_url_api_key"><![CDATA[Zadajte URL a API kľúč]]></string> + <string name="api_key">API kľúč</string> + <string name="seerr_login">Prihlásenie na Seerr server</string> + <string name="seerr_jellyfin_user">Jellyfin používateľ</string> + <string name="seerr_local_user">Lokálny používateľ</string> + <string name="search_and_download_subtitles"><![CDATA[Vyhľadať a stiahnuť titulky]]></string> + <string name="no_subtitles_found">Nenašli sa žiadne vzdialené titulky</string> + <string name="series_continueing">Prebieha</string> + <string name="in_app_screensaver">Použiť šetrič obrazovky v aplikácii</string> + <string name="delete_item">Naozaj chcete odstrániť túto položku?</string> + <string name="show_media_management">Zobraziť možnosti správy médií</string> + <string name="actor">Herec</string> + <string name="composer">Skladateľ</string> + <string name="select_all">Vybrať všetko</string> + <string name="discover">Vyhľadať</string> + <string name="quick_connect_summary">Autorizujte iné zariadenie na prihlásenie do vášho účtu</string> + <string name="send_media_info_log_to_server">Odoslať log informácií o médiách na server</string> + <string name="max_days_next_up">Max. počet dní v sekcií Nasleduje</string> + <string name="albums">Albumy</string> + <string name="artists">Umelci</string> + <string name="songs">Skladby</string> + <string name="go_to_artist">Zobraziť umelca</string> + <string name="now_playing">Práve prehrávané</string> + <string name="instant_mix">Okamžitý mix</string> + <string name="go_to_album">Zobraziť album</string> + <string name="add_to_queue">Pridať do fronty</string> + <string name="next_up_never">Nikdy</string> + <string name="external_player">Externý prehrávač</string> + <string name="album_artist">Umelec albumu</string> + <string name="album">Album</string> + <string name="popular_songs">Populárne skladby</string> + <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 new file mode 100644 index 00000000..5711dac1 --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,621 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">O aplikaciji</string> + <string name="active_recordings">Aktivni posnetki</string> + <string name="add_favorite">Dodaj med priljubljene</string> + <string name="add_server">Dodaj strežnik</string> + <string name="add_user">Dodaj uporabnika</string> + <string name="audio">Zvok</string> + <string name="birthplace">Rojstni kraj</string> + <string name="bitrate">Bitna hitrost</string> + <string name="born">Rojen</string> + <string name="cancel_recording">Prekliči Snemanje</string> + <string name="cancel_series_recording">Prekliči snemanje serije</string> + <string name="cancel">Prekliči</string> + <string name="chapters">Poglavja</string> + <string name="choose_stream">Izberi %1$s</string> + <string name="clear_image_cache">Počisti predpomnjene slike</string> + <string name="collection">Zbirka</string> + <string name="collections">Zbirke</string> + <string name="commercial">Komercialno</string> + <string name="community_rating">Ocena skupnosti</string> + <string name="compose_error_message_title">Prišlo je do napake! Pritisnite gumb, da pošljete napako na strežnik.</string> + <string name="confirm">Potrdi</string> + <string name="continue_watching">Nadaljujte z gledanjem</string> + <string name="critic_rating">Ocena kritikov</string> + <string name="decimal_seconds">%.2f sekund</string> + <string name="default_track">Privzeto</string> + <string name="delete">Izbriši</string> + <string name="died">Umrl</string> + <string name="directed_by">Režiral %1$s</string> + <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">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> + <string name="enter_server_url">Vnesite IP ali URL strežnika</string> + <string name="enter_server_address">Vnesite naslov strežnika</string> + <string name="episodes">Epizode</string> + <string name="error_loading_collection">Napaka nalaganja kolekcij %1$s</string> + <string name="external_track">Zunanji</string> + <string name="favorites">Priljubleni</string> + <string name="file_size">Velikost</string> + <string name="forced_track">Prisiljeno</string> + <string name="genres">Žanre</string> + <string name="go_to_series">Odpri serijo</string> + <string name="go_to">Pojdi na</string> + <string name="hide_controller_timeout">Skrij kontrole predvajalnika</string> + <string name="hide_debug_info">Skrij debug informacije</string> + <string name="hide">Skrij</string> + <string name="home">Domača stran</string> + <string name="immediate">Takoj</string> + <string name="intro">Uvod</string> + <string name="jump_letters">#ABCČDEFGHIJKLMNOPQRSŠTUVWXYZŽ</string> + <string name="library">Knjižnica</string> + <string name="license_info">Informacije o licenci</string> + <string name="live_tv">TV v živo</string> + <string name="loading">Nalaganje…</string> + <string name="mark_entire_series_as_played">Označi serijo kot predvajano?</string> + <string name="mark_entire_series_as_unplayed">Označi serijo kot nepredvajeno?</string> + <string name="mark_unwatched">Označi kot neogledano</string> + <string name="mark_watched">Označi kot ogledano</string> + <string name="max_bitrate">Največji bitrate</string> + <string name="more_like_this">Več takih</string> + <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> + <string name="no_data">Ni podatkov</string> + <string name="no_results">Ni rezultatov</string> + <string name="no_servers_found">Ni najdenih strežnikov</string> + <string name="no_scheduled_recordings">Ni načrtovanih posnetkov</string> + <string name="no_update_available">Ni novih posodobitev</string> + <string name="none">Nobeno</string> + <string name="only_forced_subtitles">Samo prisiljeni podanpisi</string> + <string name="outro">Outro</string> + <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> + <string name="play">Predvajaj</string> + <string name="playback_debug_info">Pokaži predvajalniške debug informacije</string> + <string name="playback_speed">Hitrost predvajanja</string> + <string name="playback">Predvajaj</string> + <string name="playlist">Seznam predvajanja</string> + <string name="playlists">Seznami predvajanja</string> + <string name="preview">Predogled</string> + <string name="profile_specific_settings">Nastavitve uporabniškega profila</string> + <string name="queue">Čakalna vrsta</string> + <string name="recap">Povzetek</string> + <string name="recently_added_in">Nedavno dodano v %1$s</string> + <string name="recently_added">Nedavno dodano</string> + <string name="recently_recorded">Nedavno posneto</string> + <string name="recently_released">Nedavno izdano</string> + <string name="recommended">Priporočeno</string> + <string name="record_program">Posnemi program</string> + <string name="record_series">Posnemi serijo</string> + <string name="remove_favorite">Odstrani iz priljubljenih</string> + <string name="restart">Zaženi znova</string> + <string name="resume">Nadaljuj</string> + <string name="save">Shrani</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> + <string name="voice_starting">Začetek</string> + <string name="voice_search_prompt">Izgovori za iskanje</string> + <string name="processing">Procesiranje</string> + <string name="press_back_to_cancel">Pritisnite nazaj, da prekličete</string> + <string name="voice_error_audio">Napaka pri snemanju zvoka</string> + <string name="voice_error_client">Napaka pri prepoznavanju glasu</string> + <string name="voice_error_permissions">Potrebno je dovoljenje do mikrofona</string> + <string name="voice_error_network">Napaka omrežja</string> + <string name="voice_error_no_match">Ni prepoznanega govora</string> + <string name="voice_error_busy">Prepoznavanje glasu zasedeno</string> + <string name="voice_error_server">Napaka strežnika</string> + <string name="voice_error_speech_timeout">Govor ni zaznan</string> + <string name="voice_error_unknown">Neznana napaka</string> + <string name="voice_error_start_failed">Prepoznavanje glasu ni uspelo zagnati</string> + <string name="voice_error_timeout">Časovna omejitev prepoznavanja glasu je potekla</string> + <string name="retry">Poskusi znova</string> + <string name="select_server">Izberite strežnik</string> + <string name="select_user">Izberite uporabnika</string> + <string name="show_debug_info">Prikaži informacije o odpravljanju napak</string> + <string name="show_next_up_when">Pokaži naslednjo</string> + <string name="show">Pokaži</string> + <string name="shuffle">Naključno</string> + <string name="skip">Preskoči</string> + <string name="sort_by_date_added">Datum dodajanja</string> + <string name="sort_by_date_episode_added">Datum dodanih epizod</string> + <string name="sort_by_date_played">Datum predvajanja</string> + <string name="sort_by_date_released">Datum izdaje</string> + <string name="sort_by_name">Imenu</string> + <string name="sort_by_random">Naključno</string> + <string name="studios">Studiji</string> + <string name="submit">Pošlji</string> + <string name="subtitle_download_too_long">Prenos traja dolgo, morda boste morali ponovno zagnati predvajanje</string> + <string name="subtitle">Podnapis</string> + <string name="subtitles">Podnapisi</string> + <string name="suggestions">Predlogi</string> + <string name="switch_servers">Zamenjaj strežnik</string> + <string name="switch_user">Preklopi</string> + <string name="top_unwatched">Najbolje ocenjeno, neogledano</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> + <string name="tv_season">Sezona</string> + <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> + <string name="updates">Novosti</string> + <string name="version">Različica</string> + <string name="video_scale">Povečava videa</string> + <string name="video">Video</string> + <string name="videos">Videji</string> + <string name="watch_live">Glej v živo</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> + <string name="aired_episode_order">Vrstni red predvajanih epizod</string> + <string name="create_playlist">Ustvari nov seznam predvajanja</string> + <string name="add_to_playlist">Dodaj na seznam predvajanja</string> + <string name="one_click_pause">Zaustavi z enim klikom</string> + <string name="one_click_pause_summary_on">Pritisni sredino smerne tipke za zaustavitev/predvajanje</string> + <string name="italic_font">Poševna pisava</string> + <string name="font">Vrsta pisave</string> + <string name="background">Ozadje</string> + <string name="success">Uspeh</string> + <string name="official_rating">Starševska ocena</string> + <string name="runtime_sort">Trajanje izvajanja</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_title">Dodaj %s med priljubljene</string> + <plurals name="downloads"> + <item quantity="one">%s prenos</item> + <item quantity="two">%s prenosa</item> + <item quantity="few">%s prenosi</item> + <item quantity="other">%s prenosov</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d ura</item> + <item quantity="two">%d uri</item> + <item quantity="few">%d ure</item> + <item quantity="other">%d ur</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dan</item> + <item quantity="two">%s dni</item> + <item quantity="few">%s dnevi</item> + <item quantity="other">%s dnevi</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d element</item> + <item quantity="two">%d elementa</item> + <item quantity="few">%d elementi</item> + <item quantity="other">%d elementov</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="two">%d sekundi</item> + <item quantity="few">%d sekunde</item> + <item quantity="other">%d sekund</item> + </plurals> + <plurals name="minutes"> + <item quantity="one">%s minuta</item> + <item quantity="two">%s minute</item> + <item quantity="few">%s minute</item> + <item quantity="other">%s minut</item> + </plurals> + <string name="ac3_supported">Naprava podpira AC3/Dolby Digital</string> + <string name="advanced_settings">Napredne nastavitve</string> + <string name="advanced_ui">Napredni uporabniški vmesnik</string> + <string name="app_theme">Tema aplikacije</string> + <string name="auto_check_for_updates">Samodejno preverjaj za posodobitve</string> + <string name="auto_play_next_delay">Časovni zamik pred predvajanjem naslednje epizode</string> + <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">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> + <string name="ffmpeg_extension_pref">Uporabi modul dekodirnika FFmpeg</string> + <string name="global_content_scale">Privzeto približanje videa</string> + <string name="install_update">Namesti posodobitev</string> + <string name="installed_version">Nameščena različica</string> + <string name="max_homepage_items">Največje število elementov v vrsticah na domači strani</string> + <string name="nav_drawer_pins_summary">Izberite privzete elemente za pokazat, ostali bodo skriti</string> + <string name="nav_drawer_pins">Prilagodite elemente v navigacijskem meniju</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknite za prehod na drugo stran</string> + <string name="nav_drawer_switch_on_focus">Preklop strani v navigacijskem meniju ob osredotočenju</string> + <string name="pass_out_protection">Zaščita pred izklopom</string> + <string name="play_theme_music">Predvajaj uvodno glasbo</string> + <string name="remember_selected_tab">Zapomni si izbrane zavihke</string> + <string name="rewatch_next_up">Omogoči ponovno predvajanje v vrsti</string> + <string name="seek_bar_steps">Koraki za iskalno vrstico</string> + <string name="send_app_logs_summary">Koristno pri odpravljanju napak</string> + <string name="send_app_logs">Pošlji dnevnike aplikacije na trenutni strežnik</string> + <string name="send_crash_reports_summary">Poskusil bom poslati na zadnji povezani strežnik</string> + <string name="send_crash_reports">Pošlji poročila o napakah</string> + <string name="settings">Nastavitve</string> + <string name="skip_back_on_resume_preference">Preskoči nazaj ob nadaljevanju predvajanja</string> + <string name="skip_back_preference">Preskoči nazaj</string> + <string name="skip_commercials_behavior">Vedenje pri preskakovanju oglasov</string> + <string name="skip_forward_preference">Preskoči naprej</string> + <string name="skip_intro_behavior">Delovanje preskoka uvoda</string> + <string name="skip_outro_behavior">Delovanje preskoka konca</string> + <string name="skip_previews_behavior">Delovanje preskoka predogleda</string> + <string name="skip_recap_behavior">Delovanje preskoka povzetka</string> + <string name="update_available">Na voljo je posodobitev</string> + <string name="update_url_summary">URL za preverjanje razpoložljivosti posodobitev aplikacije</string> + <string name="update_url">URL za posodobitev</string> + <string name="font_size">Velikost pisave</string> + <string name="font_color">Barva pisave</string> + <string name="font_opacity">Prosojnost pisave</string> + <string name="edge_style">Slog robov</string> + <string name="edge_color">Barva robov</string> + <string name="background_opacity">Prosojnost ozadja</string> + <string name="background_style">Slog ozadja</string> + <string name="subtitle_style">Slog podnapisov</string> + <string name="background_color">Barva ozadja</string> + <string name="reset">Ponastavi</string> + <string name="bold_font">Krepka pisava</string> + <string name="player_backend">Zaledni del predvajalnika</string> + <string name="mpv_hardware_decoding">MPV: Use hardware decoding</string> + <string name="disable_if_crash">Onemogoči, če pride do zrušitev</string> + <string name="mpv_options">MPV nastavitve</string> + <string name="exoplayer_options">ExoPlayer nastavitve</string> + <string name="skip_segment_unknown">Preskoči segment</string> + <string name="skip_segment_commercial">Preskoči oglase</string> + <string name="skip_segment_preview">Preskoči predogled</string> + <string name="skip_segment_recap">Preskoči povzetek</string> + <string name="skip_segment_outro">Preskoči zaključek</string> + <string name="skip_segment_intro">Preskoči uvod</string> + <string name="played">Ogledano</string> + <string name="filter">Filter</string> + <string name="year">Leto</string> + <string name="decade">Desetletje</string> + <string name="remove">Odstrani</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Uporabi gpu-next</string> + <string name="mpv_conf">Uredi datoteko mpv.conf</string> + <string name="discard_change">Želiš zavrniti spremembe?</string> + <string name="subtitle_margin">Rob</string> + <string name="verbose_logging">Podrobno beleženje</string> + <string name="enter_pin">Vnesite PIN</string> + <string name="sign_in_auto">Samodejna prijava</string> + <string name="use_server_credentials">Prijava prek strežnika</string> + <string name="press_enter_to_confirm">Pritisnite sredinski gumb za potrditev</string> + <string name="require_pin_code">Zahtevaj PIN za profil</string> + <string name="confirm_pin">Potrdi PIN</string> + <string name="incorrect">Nepravilno</string> + <string name="pin_too_short">PIN mora imeti najmanj 4 številke</string> + <string name="will_remove_pin">Odstrani PIN</string> + <string name="image_cache_size">Velikost predpomnilnika za slike (MB)</string> + <string name="view_options">Možnosti prikaza</string> + <string name="columns">Stolpci</string> + <string name="spacing">Razmik</string> + <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">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> + <string name="automatic">Avtomatsko</string> + <string name="username_or_password">Uporabi uporabniško ime/geslo</string> + <string name="login">Prijava</string> + <string name="general">Splošno</string> + <string name="container">Kontejner</string> + <string name="title">Naslov</string> + <string name="codec">Kodek</string> + <string name="profile">Profil</string> + <string name="level">Level</string> + <string name="resolution">Resolucija</string> + <string name="anamorphic">Anamorfni</string> + <string name="interlaced">Interlaced</string> + <string name="framerate">Število sličic na sekundo</string> + <string name="bit_depth">Bitna globina</string> + <string name="video_range">Video razpon</string> + <string name="video_range_type">Vrsta video obsega</string> + <string name="color_space">Barvni prostor</string> + <string name="color_transfer">Prenos barv</string> + <string name="color_primaries">Osnovne barve</string> + <string name="pixel_format">Format pikslov</string> + <string name="ref_frames">Referenčni okvirji</string> + <string name="nal">NAL</string> + <string name="language">Jezik</string> + <string name="layout">Postavitev</string> + <string name="channels">Kanali</string> + <string name="sample_rate">Frekvenca vzorčenja</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">Pokaži naslove</string> + <string name="live_tv_repeat">Ponovi</string> + <string name="favorite_channels_at_beginning">Najprej prikaži priljubljene kanale</string> + <string name="sort_channels_recently_watched">Razvrsti kanale po zadnjih ogledih</string> + <string name="color_code_programs">Barvno označi programe</string> + <string name="subtitle_delay">Zakasnitev podnapisov</string> + <string name="backdrop_display">Slog ozadja</string> + <string name="resolution_switching">Preklapljanje ločljivosti</string> + <string name="local">Lokalno</string> + <string name="play_trailer">Predvajaj napovednik</string> + <string name="no_trailers">Ni napovednikov</string> + <string name="clear_track_choices">Izbriši izbrane skladbe</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">Neposredno predvajanje v formatu Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Preskoči preverjanje združljivosti naprav</string> + <string name="discover">Odkrij</string> + <string name="request">Zahteva</string> + <string name="pending">V postopku</string> + <string name="seerr_integration">Integracija Seerr</string> + <string name="remove_seerr_server">Odstrani strežnik Seerr</string> + <string name="seerr_server_added">Dodan strežnik Seerr</string> + <string name="quick_connect">Hitro povezava</string> + <string name="quick_connect_summary">Dovoli drugi napravi, da se prijavi v tvoj račun</string> + <string name="quick_connect_code">Vnesite kodo za hitro povezavo</string> + <string name="quick_connect_code_error">Koda mora imeti 6 številk</string> + <string name="quick_connect_success">Naprava je bila uspešno odobrena</string> + <string name="password">Geslo</string> + <string name="username">Uporabniško ime</string> + <string name="url">URL</string> + <string name="trending">V trendu</string> + <string name="upcoming_movies">Prihajajoči filmi</string> + <string name="upcoming_tv">Prihajajoče televizijske oddaje</string> + <string name="request_4k">Zahtevaj v 4K</string> + <string name="software_decoding_av1">Programsko dekodiranje AV1</string> + <string name="upgrade_mpv_toast">MPV je zdaj privzeti predvajalnik, razen za HDR.\nTo lahko spremenite v nastavitvah.</string> + <string name="hdr_subtitle_style">Slog podnapisov HDR</string> + <string name="image_subtitle_opacity">Prosojnost podnapisov</string> + <string name="brightness">Svetlost</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Nasičenost</string> + <string name="hue">Barvni ton</string> + <string name="red">Rdeča</string> + <string name="green">Zelena</string> + <string name="blue">Modra</string> + <string name="blur">Zameglitev</string> + <string name="save_for_album">Shrani v album</string> + <string name="play_slideshow">Predvajaj diaprojekcijo</string> + <string name="stop_slideshow">Ustavi predvajanje diaprojekcije</string> + <string name="slideshow_at_beginning">Na začetku</string> + <string name="no_more_images">Ni več fotografij</string> + <string name="rotate_left">Zavrti v levo</string> + <string name="rotate_right">Zavrti v desno</string> + <string name="zoom_in">Približaj</string> + <string name="zoom_out">Pomanjšaj</string> + <string name="slideshow_duration">Trajanje diaprojekcije</string> + <string name="play_videos_during_slideshow">Predvajaj videoposnetke med diaprojekcijo</string> + <string name="send_media_info_log_to_server">Pošlji dnevnik medijskih podatkov na strežnik</string> + <string name="no_limit">Brez omejitve</string> + <string name="max_days_next_up">Največje število dni v »Naslednjo«</string> + <string name="albums">Albumi</string> + <string name="artists">Umetniki</string> + <string name="songs">Glsabe</string> + <string name="go_to_artist">Pojdi na stran izvajalca</string> + <string name="now_playing">Trenutno predvajano</string> + <string name="instant_mix">Instantna mešanica</string> + <string name="go_to_album">Pojdi na album</string> + <string name="add_to_queue">Dodaj v čakalno vrsto</string> + <string name="add_row">Dodaj vrstico</string> + <string name="genres_in">Žanra v %1$s</string> + <string name="recently_released_in">Nedavno izdano v %1$s</string> + <string name="height">Višina</string> + <string name="apply_all_rows">Uporabi za vse vrstice</string> + <string name="customize_home">Prilagodi domačo stran</string> + <string name="home_rows">Vrstice v domači strani</string> + <string name="load_from_server">Naloži profil uporabnika s strežnika</string> + <string name="save_to_server">Shrani v profil uporabnika na strežniku</string> + <string name="load_from_web_client">Naloži iz spletnega odjemalca</string> + <string name="use_series">Uporabi sliko iz serije</string> + <string name="add_row_for">Dodaj vrstico za %1$s</string> + <string name="overwrite_server_settings">Želiš prepisati nastavitve na strežniku?</string> + <string name="overwrite_local_settings">Želiš prepisati lokalne nastavitve?</string> + <string name="for_episodes">Za epizode</string> + <string name="suggestions_for">Predlogi za %1$s</string> + <string name="increase_all_cards_size">Povečaj velikost vseh kartic</string> + <string name="decrease_all_cards_size">Zmanjšaj velikost vseh kartic</string> + <string name="use_thumb_images">Uporabi thumb slike</string> + <string name="settings_saved">Nastavitve shranjene</string> + <string name="display_presets">Prednastavitve zaslona</string> + <string name="display_presets_description">Vgrajene prednastavitve za hitro oblikovanje vseh vrstic</string> + <string name="customize_home_summary">Izberite vrstice in slike na domači strani</string> + <string name="start_after">Začni po</string> + <string name="animate">Animiraj</string> + <string name="max_age_rating">Najvišja starostna omejitev</string> + <string name="no_max">Brez maksimuma</string> + <string name="for_all_ages">Za vse starosti</string> + <string name="up_to_age">Do starosti %s</string> + <string name="screensaver">Ohranjevalnik zaslona</string> + <string name="screensaver_settings">Nastavitve ohranjevalnika zaslona</string> + <string name="start_screensaver">Zaženi ohranjevalnik zaslona</string> + <string name="duration">Trajanje</string> + <string name="photos">Slike</string> + <string name="include_types">Vključi vrste</string> + <string name="include_types_summary">Za katere vrste predmetov naj se prikažejo slike</string> + <string name="content_scale_fit">Fit</string> + <string name="content_scale_crop">Obreži</string> + <string name="content_scale_fill">Napolni</string> + <string name="content_scale_fill_width">Napolni širino</string> + <string name="content_scale_fill_height">Napolni višino</string> + <string name="display_preset_default">Privzete nastavitve Wholpin-a</string> + <string name="display_preset_compact">Kompakten Wholpin</string> + <string name="display_preset_series_thumb">Thumb slike TV serij</string> + <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> + <string name="volume_lowest">Najnižja</string> + <string name="volume_low">Nizko</string> + <string name="volume_medium">Srednje</string> + <string name="volume_high">Visoko</string> + <string name="volume_full">Polno</string> + <string name="purple">Vijolično</string> + <string name="orange">Oranžno</string> + <string name="bold_blue">Krepko modro</string> + <string name="black">Črno</string> + <string name="skip_ignore">Prezri</string> + <string name="skip_automatically">Samodejno preskoči</string> + <string name="skip_ask">Vprašaj, če prekoči</string> + <string name="ffmpeg_fallback">FFmpeg uporabi le, če ni na voljo vgrajenega dekodirnika</string> + <string name="ffmpeg_prefer">Raje uporabi FFmpeg kot vgrajene dekodirnike</string> + <string name="ffmpeg_never">Nikoli ne uporabljaj dekoderje FFmpeg</string> + <string name="next_up_playback_end">Na koncu predvajanja</string> + <string name="next_up_outro">Med zaključnimi napisi</string> + <string name="next_up_never">Nikoli</string> + <string name="white">Belo</string> + <string name="light_gray">Svetlo sivo</string> + <string name="dark_gray">Temno sivo</string> + <string name="yellow">Rumeno</string> + <string name="cyan">Cian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Oris</string> + <string name="subtitle_edge_shadow">Senca</string> + <string name="background_style_wrap">Ovij</string> + <string name="background_style_boxed">Boxed</string> + <string name="prefer_mpv">Raje izberi MPV</string> + <string name="external_player">Zunanji predvajalnik</string> + <string name="player_backend_options_subtitles_prefer_mpv">Za predvajanje HDR uporabite ExoPlayer</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">Kvadratno (1:1)</string> + <string name="image_type_primary">Primarno</string> + <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">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">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> + <string name="delete_item">Ali ste prepričani, da želite izbrisati ta element?</string> + <string name="show_media_management">Prikaži možnosti za upravljanje medijev</string> + <string name="search_for">Išči za %s</string> + <string name="actor">Igralec</string> + <string name="composer">Skladatelj</string> + <string name="writer">Pisatelj</string> + <string name="guest_star">Gostujoči igralec</string> + <string name="producer">Producent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Avtor besedila</string> + <string name="arranger">Aranžer</string> + <string name="engineer">Inženir</string> + <string name="mixer">Mešalnik</string> + <string name="creator">Avtor</string> + <string name="artist">Umetnik</string> + <string name="album_artist">Izvajalec albuma</string> + <string name="album">Album</string> + <string name="popular_songs">Priljubljene glasbe</string> + <string name="play_next">Predvajaj naslednjo</string> + <string name="music_videos">Glasbeni videospoti</string> + <string name="lyrics">Besedilo</string> + <string name="hide_lyrics">Skrij besedilo</string> + <string name="show_lyrics">Pokaži besedilo</string> + <string name="song_has_lyrics">Glsaba ima besedilo</string> + <string name="remove_from_queue">Odstrani iz čakalne vrste</string> + <string name="show_album_cover">Pokaži naslovnico albuma</string> + <string name="show_visualizer">Prikaži vizualizator</string> + <string name="show_backdrop">Prikaži ozadje</string> + <string name="play_as_type">Predvajaj kot?</string> + <string name="visualizer_rationale">Za vizualizacijo zvoka je potrebno dovoljenje za snemanje zvoka.</string> + <string name="continue_string">Nadaljuj</string> + <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">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> + <string name="view_more">Poglej več</string> + <string name="discover_tv">Odkrijte TV oddaje</string> + <string name="discover_movies">Odkrijte filme</string> + <string name="studios_in">Studii v %1$s</string> + <string name="last_played">Nazadnje predvajano</string> + <string name="system_default">Privzete nastavitve sistema</string> + <string name="ass_subtitle_mode_libass">Neposredno predvajanje z libass</string> + <string name="ass_subtitle_mode_exoplayer">Neposredno predvajanje z vgrajenim predvajalnikom ExoPlayer</string> + <string name="ass_subtitle_mode_transcode">Pretvorba na strežniku</string> + <string name="ass_subtitle_playback">Predvajanje podnapisov SSA/ASS</string> + <string name="cinema_mode">Kino način</string> + <string name="show_more">Pokaži %1$s več</string> + <string name="skip_behavior">Delovanje preskokov</string> + <string name="skip_behavior_summary">Za uvodne napise, napise ob koncu filma itd.</string> +</resources> 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 b7d13882..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> @@ -277,4 +279,416 @@ <string name="enter_pin">Skriv in PIN</string> <string name="sign_in_auto">Logga in automatiskt</string> <string name="use_server_credentials">Logga in via server</string> + <string name="enter_server_address">Ange server-adress</string> + <string name="voice_error_network">Nätverksfel</string> + <string name="voice_error_unknown">Okänt fel</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minuter</item> + </plurals> + <string name="bold_font">Fet stil</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="discard_change">Avbryt ändringar?</string> + <string name="require_pin_code">Kräv PIN för profilen</string> + <string name="resolution">Upplösning</string> + <string name="language">Språk</string> + <string name="yes">Ja</string> + <string name="no">Nej</string> + <string name="live_tv_repeat">Upprepa</string> + <string name="password">Lösenord</string> + <string name="username">Användarnamn</string> + <string name="creator">Skapare</string> + <string name="engineer">Ingenjör</string> + <string name="actor">Skådespelare</string> + <string name="composer">Kompositör</string> + <string name="writer">Författare</string> + <string name="producer">Producent</string> + <string name="backdrop_style_image">Endast bild</string> + <string name="white">Vit</string> + <string name="light_gray">Ljusgrå</string> + <string name="dark_gray">Mörkgrå</string> + <string name="yellow">Gul</string> + <string name="purple">Lila</string> + <string name="black">Svart</string> + <string name="for_all_ages">För alla åldrar</string> + <string name="up_to_age">Upp till %s år</string> + <string name="screensaver">Skärmsläckare</string> + <string name="screensaver_settings">Inställningar Skärmsläckare</string> + <string name="start_screensaver">Starta skärmsläckare</string> + <string name="photos">Bilder</string> + <string name="settings_saved">Inställningar sparade</string> + <string name="rotate_left">Rotera vänster</string> + <string name="rotate_right">Rotera höger</string> + <string name="zoom_in">Zooma in</string> + <string name="zoom_out">Zooma ut</string> + <string name="no_limit">Ingen gräns</string> + <string name="add_row">Lägg till rad</string> + <string name="press_back_to_cancel">Tryck bakåt för att avbryta</string> + <string name="duration">Längd</string> + <string name="orange">Orange</string> + <string name="blue">Blå</string> + <string name="backdrop_style_dynamic">Bild med dynamisk färg</string> + <string name="background_style">Bakgrundsstil</string> + <string name="backdrop_display">Bakgrundsstil</string> + <string name="edge_size">Kantstorlek</string> + <string name="quick_connect_code_error">Koden måste vara 6 siffror</string> + <string name="quick_connect_code">Ange Quick Connect-kod</string> + <string name="quick_connect_success">Enheten har auktoriserats</string> + <string name="quick_connect_summary">Auktorisera en annan enhet att logga in på ditt konto</string> + <string name="recently_released_in">Nyligen släppt i %1$s</string> + <string name="green">Grön</string> + <string name="profile">Profil</string> + <string name="quick_connect">Snabbanslutning</string> + <string name="remove_seerr_server">Ta bort Seerr-server</string> + <string name="request_4k">Begär i 4K</string> + <string name="request">Begär</string> + <string name="remember_selected_tab">Kom ihåg vald flik</string> + <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 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> + <string name="nav_drawer_pins">Anpassa navigationsmenyn</string> + <string name="nav_drawer_pins_summary">Välj vilka objekt som ska visas som standard, övriga döljs</string> + <string name="nav_drawer_switch_on_focus">Växla sidor i navigationsmenyn vid fokus</string> + <string name="nav_drawer_switch_on_focus_summary_off">Klicka för att växla sida</string> + <string name="next_up_playback_end">Vid slutet av uppspelningen</string> + <string name="next_up_outro">Under eftertexter/slutsekvens</string> + <string name="pixel_format">Pixelformat</string> + <string name="pin_too_short">PIN måste vara minst 4 tecken</string> + <string name="overwrite_server_settings">Skriv över inställningar på servern?</string> + <string name="overwrite_local_settings">Skriv över lokala inställningar?</string> + <string name="seerr_integration">Seerr-integration</string> + <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</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> + <string name="search_for">Sök %s</string> + <string name="send_media_info_log_to_server">Skicka medieinformationslogg till server</string> + <string name="send_crash_reports_summary">Försöker skicka till senast anslutna server</string> + <string name="series_continueing">Pågående</string> + <string name="show_media_management">Visa alternativ för mediahantering</string> + <string name="show_titles">Visa titlar</string> + <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">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> + <string name="sample_rate">Samplingsfrekvens</string> + <string name="sample_rate_unit">Hz</string> + <string name="voice_error_server">Serverfel</string> + <string name="voice_error_speech_timeout">Inget tal upptäcktes</string> + <string name="voice_error_start_failed">Kunde inte starta röstigenkänning</string> + <string name="voice_error_timeout">Röstigenkänning tog för lång tid</string> + <string name="skip_commercials_behavior">Beteende för att hoppa över reklam</string> + <string name="skip_back_preference">Hoppa tillbaka</string> + <string name="skip_back_on_resume_preference">Hoppa tillbaka vid återupptagen uppspelning</string> + <string name="skip_ignore">Ignorera</string> + <string name="skip_intro_behavior">Beteende för att hoppa över intro</string> + <string name="skip_outro_behavior">Beteende för att hoppa över outro</string> + <string name="skip_previews_behavior">Beteende för att hoppa över förhandsvisningar</string> + <string name="skip_recap_behavior">Beteende för att hoppa över repris</string> + <string name="url">URL</string> + <string name="verbose_logging">Utförlig loggning</string> + <string name="upcoming_movies">Kommande filmer</string> + <string name="upcoming_tv">Kommande TV-serier</string> + <string name="update_url_summary">Webbadress som används för att kontrollera appuppdateringar</string> + <string name="upgrade_mpv_toast">MPV är nu standardspelaren förutom för HDR. Du kan ändra detta i inställningarna.</string> + <string name="use_series">Använd seriebild</string> + <string name="use_thumb_images">Använd miniatyrbilder</string> + <string name="home_rows">Rader på startsidan</string> + <string name="general">Allmänt</string> + <string name="guest_star">Gästskådespelare</string> + <string name="hdr_subtitle_style">HDR-undertextstil</string> + <string name="height">Höjd</string> + <string name="image_subtitle_opacity">Opacitet för bildundertexter</string> + <string name="hue">Nyans</string> + <string name="image_type_thumb">Miniatyr</string> + <string name="image_type_primary">Primär</string> + <string name="conductor">Dirigent</string> + <string name="container">Container</string> + <string name="content_scale_crop">Beskär</string> + <string name="content_scale_fill_height">Fyll höjd</string> + <string name="content_scale_fit">Anpassa</string> + <string name="content_scale_fill_width">Fyll bredd</string> + <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 radera detta objekt?</string> + <string name="discover">Upptäck</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 new file mode 100644 index 00000000..2ccccbae --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,597 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">เกี่ยวกับแอปนี้</string> + <string name="active_recordings">การบันทึกที่ใช้งานอยู่</string> + <string name="add_favorite">ชื่นชอบ</string> + <string name="add_server">เพิ่มเซิร์ฟเวอร์</string> + <string name="add_user">เพิ่มผู้ใช้</string> + <string name="audio">เสียง</string> + <string name="birthplace">บ้านเกิด</string> + <string name="bitrate">บิตเรท</string> + <string name="born">เกิด</string> + <string name="cancel_recording">ยกเลิกการบันทึก</string> + <string name="cancel_series_recording">ยกเลิกการบันทึกซีรีส์</string> + <string name="cancel">ยกเลิก</string> + <string name="chapters">ฉาก</string> + <string name="choose_stream">เลือก %1$s</string> + <string name="clear_image_cache">ล้างแคชภาพ</string> + <string name="collection">คอลเลคชัน</string> + <string name="collections">คอลเลกชัน</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="critic_rating">การจัดอันดับนักวิจารณ์</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="director">ผู้กำกับ</string> + <string name="disabled">ปิดใช้งาน</string> + <string name="discovered_servers">เซิร์ฟเวอร์ที่ค้นพบ</string> + <string name="download_and_update"><![CDATA[ดาวน์โหลดและอัปเดต]]></string> + <string name="downloading">กำลังดาวน์โหลด…</string> + <string name="enabled">ใช้งาน</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> + <string name="error_loading_collection">เกิดข้อผิดพลาดในการโหลดคอลเล็กชัน %1$s</string> + <string name="external_track">ภายนอก</string> + <string name="favorites">ชื่นชอบ</string> + <string name="file_size">ขนาด</string> + <string name="forced_track">บังคับ</string> + <string name="genres">แนวเนื้อหา</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> + <string name="home">หน้าหลัก</string> + <string name="immediate">ทันที</string> + <string name="intro">บทนำ</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">คลัง</string> + <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_unwatched">ทำเครื่องหมายว่ายังไม่ได้ดู</string> + <string name="mark_watched">ทำเครื่องหมายว่าดูแล้ว</string> + <string name="max_bitrate">บิตเรตสูงสุด</string> + <string name="more_like_this">เนื้อหาอื่นที่คล้ายกัน</string> + <string name="more">เพิ่มเติม</string> + <plurals name="movies"> + <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_servers_found">ไม่พบเซิร์ฟเวอร์</string> + <string name="no_scheduled_recordings">ไม่มีการบันทึกตามกำหนดเวลา</string> + <string name="no_update_available">ไม่มีการอัปเดต</string> + <string name="none">ไม่มี</string> + <string name="only_forced_subtitles">คำบรรยายแบบบังคับเท่านั้น</string> + <string name="outro">เอาท์โตร</string> + <string name="path">เส้นทาง</string> + <plurals name="people"> + <item quantity="other">ผู้คน</item> + </plurals> + <string name="play_count">จำนวนครั้งที่เล่น</string> + <string name="play_from_here">เล่นจากที่นี่</string> + <string name="play">เล่น</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="preview">ดูตัวอย่าง</string> + <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_recorded">บันทึกล่าสุด</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="restart">เริ่มใหม่</string> + <string name="resume">เล่นต่อ</string> + <string name="save">บันทึก</string> + <string name="search_and_download"><![CDATA[ค้นหาและดาวน์โหลด]]></string> + <string name="search">ค้นหา</string> + <string name="searching">ค้นหา…</string> + <string name="voice_search">ค้นหาด้วยเสียง</string> + <string name="voice_starting">กำลังเริ่มต้น</string> + <string name="voice_search_prompt">พูดเพื่อค้นหา</string> + <string name="processing">กำลังประมวลผล</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_no_match">ไม่มีคำพูดใดที่ได้รับการยอมรับ</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="select_server">เลือกเซิร์ฟเวอร์</string> + <string name="select_user">เลือกผู้ใช้งาน</string> + <string name="show_debug_info">แสดงข้อมูลการแก้ไขข้อบกพร่อง</string> + <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="sort_by_date_played">วันที่เล่น</string> + <string name="sort_by_date_released">วันที่เผยแพร่</string> + <string name="sort_by_name">ชื่อ</string> + <string name="sort_by_random">สุ่ม</string> + <string name="studios">สตูดิโอ</string> + <string name="submit">ยืนยัน</string> + <string name="subtitle_download_too_long">การดาวน์โหลดใช้เวลานาน คุณอาจต้องเริ่มเล่นใหม่</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> + <plurals name="trailers"> + <item quantity="other">ตัวอย่าง</item> + </plurals> + <string name="tv_dvr_schedule">กำหนดการบันทึก</string> + <string name="tv_guide">ผังรายการ</string> + <string name="tv_season">ซีซั่น</string> + <string name="tv_seasons">ซีซั่น</string> + <plurals name="tv_shows"> + <item quantity="other">รายการทีวี</item> + </plurals> + <string name="ui_interface">อินเทอร์เฟซ</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="watch_live">ชมสด</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_summary_on">กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น</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_title">เพิ่มเติม</string> + <plurals name="other_extras"> + <item quantity="other">อื่นๆ</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="other">เบื้องหลัง</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="other">เพลงธีม</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="other">วิดีโอธีม</item> + </plurals> + <plurals name="clips"> + <item quantity="other">คลิป</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="other">ฉากที่ถูกลบ</item> + </plurals> + <plurals name="interviews"> + <item quantity="other">สัมภาษณ์</item> + </plurals> + <plurals name="scenes"> + <item quantity="other">ซีน</item> + </plurals> + <plurals name="samples"> + <item quantity="other">ตัวอย่าง</item> + </plurals> + <plurals name="featurettes"> + <item quantity="other">สารคดี</item> + </plurals> + <plurals name="shorts"> + <item quantity="other">หนังสั้น</item> + </plurals> + <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> + <string name="app_theme">ธีมแอปพลิเคชัน</string> + <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="combine_continue_next"><![CDATA[รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน]]></string> + <string name="direct_play_ass">เล่นคำบรรยาย ASS โดยตรงด้วย libass</string> + <string name="direct_play_pgs">เล่นคำบรรยาย PGS โดยตรง</string> + <string name="downmix_stereo">แปลงเสียงเป็นสเตอริโอเสมอ</string> + <string name="ffmpeg_extension_pref">ใช้โมดูลถอดรหัส FFmpeg</string> + <string name="global_content_scale">ขนาดเนื้อหาเริ่มต้น</string> + <string name="install_update">ติดตั้งอัปเดต</string> + <string name="installed_version">เวอร์ชันที่ติดตั้ง</string> + <string name="max_homepage_items">จำนวนรายการสูงสุดในแถวหน้าแรก</string> + <string name="nav_drawer_pins_summary">เลือกรายการเริ่มต้นที่จะแสดง รายการอื่นๆ จะถูกซ่อนไว้</string> + <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="play_theme_music">เล่นเพลงธีม</string> + <string name="playback_overrides">การเล่นจะแทนที่</string> + <string name="remember_selected_tab">จำแท็บที่เลือก</string> + <string name="rewatch_next_up">เปิดใช้งานการรับชมซ้ำในครั้งต่อไป</string> + <string name="seek_bar_steps">ค้นหาขั้นบันได</string> + <string name="send_app_logs_summary">มีประโยชน์สำหรับการแก้ไขข้อผิดพลาด</string> + <string name="send_app_logs">ส่งบันทึกแอปไปยังเซิร์ฟเวอร์ปัจจุบัน</string> + <string name="send_crash_reports_summary">จะพยายามส่งไปยังเซิร์ฟเวอร์ที่เชื่อมต่อล่าสุด</string> + <string name="send_crash_reports">ส่งรายงานข้อขัดข้อง</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> + <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_color">สีขอบ</string> + <string name="background_opacity">ความเข้มจางของพื้นหลัง</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="player_backend">การเล่นแบ็กเอนด์</string> + <string name="mpv_hardware_decoding">MPV: ใช้การถอดรหัสฮาร์ดแวร์</string> + <string name="disable_if_crash">ปิดใช้งานหากพบปัญหาโปรแกรมหยุดทำงาน</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> + <string name="skip_segment_preview">ข้ามการแสดงตัวอย่าง</string> + <string name="skip_segment_recap">ข้ามสรุป</string> + <string name="skip_segment_outro">ข้ามเอาท์โทร</string> + <string name="skip_segment_intro">ข้ามบทนำ</string> + <string name="played">เล่นแล้ว</string> + <string name="filter">ตัวกรอง</string> + <string name="year">ปี</string> + <string name="decade">ทศวรรษ</string> + <string name="remove">ลบ</string> + <string name="dolby_vision">ดอลบี้ วิชั่น</string> + <string name="discard_change">ทิ้งการเปลี่ยนแปลง?</string> + <string name="subtitle_margin">ระยะห่างจากด้านล่าง</string> + <string name="verbose_logging">การบันทึกข้อมูลแบบละเอียด</string> + <string name="enter_pin">ป้อนรหัส PIN</string> + <string name="sign_in_auto">ลงชื่อเข้าใช้โดยอัตโนมัติ</string> + <string name="use_server_credentials">เข้าสู่ระบบผ่านเซิร์ฟเวอร์</string> + <string name="press_enter_to_confirm">กดตรงกลางเพื่อยืนยัน</string> + <string name="require_pin_code">ต้องใส่รหัส PIN สำหรับโปรไฟล์</string> + <string name="confirm_pin">ยืนยันรหัส PIN</string> + <string name="incorrect">ไม่ถูกต้อง</string> + <string name="pin_too_short">รหัส PIN ต้องมีอย่างน้อย 4 ปุ่ม</string> + <string name="will_remove_pin">จะลบ PIN</string> + <string name="image_cache_size">ขนาดแคชดิสก์รูปภาพ (เมกะไบต์)</string> + <string name="view_options">ตัวเลือกการดู</string> + <string name="columns">คอลัมน์</string> + <string name="spacing">ระยะห่าง</string> + <string name="aspect_ratio">อัตราส่วนภาพ</string> + <string name="show_details">แสดงรายละเอียด</string> + <string name="image_type">ประเภทรูปภาพ</string> + <string name="cast_and_crew"><![CDATA[นักแสดงและทีมงาน]]></string> + <string name="guest_stars">ดารารับเชิญ</string> + <string name="edge_size">ขนาดขอบ</string> + <string name="refresh_rate_switching">การสลับอัตราการรีเฟรช</string> + <string name="automatic">อัตโนมัติ</string> + <string name="username_or_password">ใช้ชื่อผู้ใช้/รหัสผ่าน</string> + <string name="login">เข้าสู่ระบบ</string> + <string name="general">ทั่วไป</string> + <string name="container">คอนเทนเนอร์</string> + <string name="title">ชื่อเรื่อง</string> + <string name="codec">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="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_primaries">แม่สี</string> + <string name="pixel_format">รูปแบบพิกเซล</string> + <string name="ref_frames">เฟรมอ้างอิง</string> + <string name="nal">NAL</string> + <string name="language">ภาษา</string> + <string name="layout">รูปแบบ</string> + <string name="channels">ช่อง</string> + <string name="sample_rate">อัตราการสุ่มตัวอย่าง</string> + <string name="avc">AVC</string> + <string name="yes">ใช่</string> + <string name="no">ไม่</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">แสดงชื่อเรื่อง</string> + <string name="live_tv_repeat">ทำซ้ำ</string> + <string name="favorite_channels_at_beginning">แสดงช่องโปรดก่อน</string> + <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="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="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</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">เล่น Dolby Vision Profile 7 โดยตรง</string> + <string name="force_dovi_profile_7_summary">ไม่สนใจการตรวจสอบความเข้ากันได้ของอุปกรณ์</string> + <string name="discover">ค้นพบ</string> + <string name="request">คำขอ</string> + <string name="pending">รอดำเนินการ</string> + <string name="seerr_integration">การผสานรวม Seerr</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="password">รหัสผ่าน</string> + <string name="username">ชื่อผู้ใช้</string> + <string name="url">URL</string> + <string name="trending">เทรนด์</string> + <string name="upcoming_movies">ภาพยนตร์ที่จะเกิดขึ้น</string> + <string name="upcoming_tv">รายการทีวีที่จะออกอากาศเร็วๆ นี้</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="hdr_subtitle_style">รูปแบบคำบรรยาย HDR</string> + <string name="image_subtitle_opacity">ความทึบของคำบรรยายภาพ</string> + <string name="brightness">ความสว่าง</string> + <string name="contrast">คอนทราสต์</string> + <string name="saturation">ท้องอิ่ม</string> + <string name="hue">เนื้อสี</string> + <string name="red">แดง</string> + <string name="green">เขียว</string> + <string name="blue">น้ำเงิน</string> + <string name="blur">เบลอ</string> + <string name="save_for_album">บันทึกไว้ในอัลบั้ม</string> + <string name="play_slideshow">เล่นสไลด์โชว์</string> + <string name="stop_slideshow">หยุดสไลด์โชว์</string> + <string name="slideshow_at_beginning">เมื่อเริ่มต้น</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="max_days_next_up">จำนวนวันสูงสุดใน 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="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> + <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="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_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="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="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="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="backdrop_style_dynamic">ภาพที่มีสีแบบไดนามิก</string> + <string name="backdrop_style_image">รูปภาพเท่านั้น</string> + <string name="enter_url_api_key"><![CDATA[ป้อน URL และ API Key]]></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">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 745da44f..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">Seç: %1$s</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> @@ -52,7 +52,7 @@ <string name="home">Ana Ekran</string> <string name="immediate">Hemen</string> <string name="intro">İntro</string> - <string name="jump_letters">#ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> <string name="library">Kütüphane</string> <string name="license_info">Lisans bilgileri</string> <string name="live_tv">Canlı TV</string> @@ -63,10 +63,10 @@ <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">Filmler</item> - <item quantity="other"/> + <item quantity="one">Film</item> + <item quantity="other">Filmler</item> </plurals> <string name="name">Ad</string> <string name="next_up">Sıradaki bölümler</string> @@ -80,8 +80,8 @@ <string name="outro">Kapanış jeneriği</string> <string name="path">Yol</string> <plurals name="people"> - <item quantity="one">Kişiler</item> - <item quantity="other"/> + <item quantity="one">Kişi</item> + <item quantity="other">Kişiler</item> </plurals> <string name="play_count">Oynatma Sayısı</string> <string name="play_from_here">Buradan oynat</string> @@ -149,18 +149,17 @@ <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">Fragmanlar</item> - <item quantity="other"/> + <item quantity="one">Fragman</item> + <item quantity="other">Fragmanlar</item> </plurals> <string name="tv_dvr_schedule">Kayıt Takvimi</string> <string name="tv_guide">Rehber</string> <string name="tv_season">Sezon</string> <string name="tv_seasons">Sezonlar</string> <plurals name="tv_shows"> - <item quantity="one">Diziler</item> - <item quantity="other"/> + <item quantity="one">Dizi</item> + <item quantity="other">Diziler</item> </plurals> <string name="ui_interface">Arayüz</string> <string name="unknown">Bilinmiyor</string> @@ -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,50 +187,50 @@ <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 Ekstralar</item> - <item quantity="other"/> + <item quantity="one">Diğer</item> + <item quantity="other">Diğer</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Sahne Arkası</item> - <item quantity="other"/> + <item quantity="other">Sahne Arkaları</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tema şarkıları</item> - <item quantity="other"/> + <item quantity="one">Tema şarkısı</item> + <item quantity="other">Tema şarkıları</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tema Videoları</item> - <item quantity="other"/> + <item quantity="one">Tema Videosu</item> + <item quantity="other">Tema Videoları</item> </plurals> <plurals name="clips"> - <item quantity="one">Klipler</item> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="other">Klipler</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Silinmiş Sahneler</item> - <item quantity="other"/> + <item quantity="one">Silinmiş Sahne</item> + <item quantity="other">Silinmiş Sahneler</item> </plurals> <plurals name="interviews"> - <item quantity="one">Röportajlar</item> - <item quantity="other"/> + <item quantity="one">Röportaj</item> + <item quantity="other">Röportajlar</item> </plurals> <plurals name="scenes"> - <item quantity="one">Sahneler</item> - <item quantity="other"/> + <item quantity="one">Sahne</item> + <item quantity="other">Sahneler</item> </plurals> <plurals name="samples"> - <item quantity="one">Örnekler</item> - <item quantity="other"/> + <item quantity="one">Örnek</item> + <item quantity="other">Örnekler</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Tanıtımlar</item> - <item quantity="other"/> + <item quantity="one">Özel Seçenek</item> + <item quantity="other">Özel Seçenekler</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kısa Videolar</item> - <item quantity="other"/> + <item quantity="one">Kısa Video</item> + <item quantity="other">Kısa Videolar</item> </plurals> <plurals name="downloads"> <item quantity="one">%s indirme</item> @@ -240,7 +242,7 @@ </plurals> <plurals name="items"> <item quantity="one">%d öğe</item> - <item quantity="other">%d öğe</item> + <item quantity="other">%d öğeler</item> </plurals> <plurals name="seconds"> <item quantity="one">%d saniye</item> @@ -256,7 +258,7 @@ <string name="check_for_updates">Güncellemeleri kontrol et</string> <string name="combine_continue_next_summary">Yalnızca diziler için geçerlidir</string> <string name="combine_continue_next"><![CDATA[İzlemeye Devam Et ve Sıradaki Bölümleri Birleştir]]></string> - <string name="direct_play_ass">ASS altyazıları doğrudan oynat</string> + <string name="direct_play_ass">ASS altyazıları için libass kullan</string> <string name="direct_play_pgs">PGS altyazıları doğrudan oynat</string> <string name="downmix_stereo">Her zaman Stereo\'ya dönüştür</string> <string name="ffmpeg_extension_pref">FFmpeg kod çözücü kullan</string> @@ -394,6 +396,7 @@ <string name="clear_track_choices">Parça seçimlerini temizle</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> @@ -436,4 +439,324 @@ <string name="zoom_out">Uzaklaştır</string> <string name="slideshow_duration">Slayt Gösterisi Süresi</string> <string name="play_videos_during_slideshow">Slayt gösterisi sırasında videoları oynat</string> + <plurals name="days"> + <item quantity="one">%s gün</item> + <item quantity="other">%s günler</item> + </plurals> + <string name="send_media_info_log_to_server">Medya bilgisini sunucuya gönder</string> + <string name="no_limit">Sınırsız</string> + <string name="max_days_next_up">Sıradaki bölümlerde kalacağı gün sayısı</string> + <string name="quick_connect">Hızlı Bağlan</string> + <string name="quick_connect_summary">Başka bir cihazın giriş yapmasına izin ver</string> + <string name="quick_connect_code">Hızlı Bağlan kodunu girin</string> + <string name="quick_connect_code_error">Kod 6 haneli olmalıdır</string> + <string name="quick_connect_success">Cihaz başarıyla yetkilendirildi</string> + <string name="add_row">Satır ekle</string> + <string name="genres_in">%1$s içindeki türler</string> + <string name="recently_released_in">%1$s kütüphanesinde son çıkanlar</string> + <string name="height">Yükseklik</string> + <string name="apply_all_rows">Tüm satırlara uygula</string> + <string name="customize_home">Ana ekranı özelleştir</string> + <string name="home_rows">Ana ekran satırları</string> + <string name="load_from_server">Kullanıcı profilinden yükle</string> + <string name="save_to_server">Kullanıcı profiline kaydet</string> + <string name="load_from_web_client">Web istemcisinden yükle</string> + <string name="use_series">Dizi görselini kullan</string> + <string name="add_row_for">%1$s için satır ekle</string> + <string name="overwrite_server_settings">Sunucudaki ayarların üzerine yazılsın mı?</string> + <string name="overwrite_local_settings">Yerel ayarların üzerine yazılsın mı?</string> + <string name="for_episodes">Bölümler için</string> + <string name="suggestions_for">%1$s için tavsiyeler</string> + <string name="increase_all_cards_size">Tüm kartların boyutunu artır</string> + <string name="decrease_all_cards_size">Tüm kartların boyutunu azalt</string> + <string name="use_thumb_images">Küçük resimleri kullan</string> + <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_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">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> + <string name="volume_low">Düşük</string> + <string name="volume_medium">Orta</string> + <string name="volume_high">Yüksek</string> + <string name="volume_full">Tam</string> + <string name="purple">Mor</string> + <string name="orange">Turuncu</string> + <string name="bold_blue">Koyu Mavi</string> + <string name="black">Siyah</string> + <string name="skip_ignore">Yoksay</string> + <string name="skip_automatically">Otomatik Atla</string> + <string name="skip_ask">Atlamak için sor</string> + <string name="ffmpeg_fallback">Dahili çözücü yoksa FFmpeg kullan</string> + <string name="ffmpeg_prefer">Dahili çözücüler yerine FFmpeg\'i tercih et</string> + <string name="ffmpeg_never">FFmpeg çözücülerini asla kullanma</string> + <string name="next_up_playback_end">Oynatma sonunda</string> + <string name="next_up_outro">Kapanış bölümünde / Jenerik sırasında</string> + <string name="white">Beyaz</string> + <string name="light_gray">Açık Gri</string> + <string name="dark_gray">Koyu Gri</string> + <string name="yellow">Sarı</string> + <string name="cyan">Camgöbeği</string> + <string name="magenta">Macenta</string> + <string name="subtitle_edge_outline">Kenarlık</string> + <string name="subtitle_edge_shadow">Gölge</string> + <string name="background_style_wrap">Kapla</string> + <string name="background_style_boxed">Kutu içinde</string> + <string name="prefer_mpv">MPV\'yi tercih et</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR oynatma için ExoPlayer kullan</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">Kare (1:1)</string> + <string name="image_type_primary">Birincil</string> + <string name="image_type_thumb">Küçük Resim</string> + <string name="backdrop_style_dynamic">Dinamik renkli görsel</string> + <string name="backdrop_style_image">Sadece görsel</string> + <string name="enter_url_api_key"><![CDATA[URL ve API Anahtarını Girin]]></string> + <string name="api_key">API Anahtarı</string> + <string name="seerr_login">Seerr sunucusuna giriş yap</string> + <string name="seerr_jellyfin_user">Jellyfin kullanıcısı</string> + <string name="seerr_local_user">Yerel kullanıcı</string> + <string name="search_and_download_subtitles"><![CDATA[Altyazı ara ve indir]]></string> + <string name="no_subtitles_found">Uzak altyazı bulunamadı</string> + <string name="series_continueing">Günümüz</string> + <plurals name="minutes"> + <item quantity="one">%s dakika</item> + <item quantity="other">%s dakika</item> + </plurals> + <string name="start_after">Başlama süresi</string> + <string name="animate">Oynatma Süresi</string> + <string name="max_age_rating">Maksimum yaş sınırı</string> + <string name="no_max">Sınır yok</string> + <string name="for_all_ages">Genel İzleyici</string> + <string name="up_to_age">%s yaşa kadar</string> + <string name="screensaver">Ekran koruyucu</string> + <string name="screensaver_settings">Ekran koruyucu ayarları</string> + <string name="start_screensaver">Ekran koruyucuyu başlat</string> + <string name="duration">Süre</string> + <string name="photos">Fotoğraflar</string> + <string name="include_types">İçerik türleri</string> + <string name="include_types_summary">Görsel gösterilecek öğe türleri</string> + <string name="in_app_screensaver">Uygulama içi ekran koruyucuyu kullan</string> + <string name="actor">Aktör</string> + <string name="composer">Besteci</string> + <string name="writer">Yazar</string> + <string name="guest_star">Konuk sanatçı</string> + <string name="producer">Yapımcı</string> + <string name="conductor">Orkestra şefi</string> + <string name="lyricist">Söz yazarı</string> + <string name="arranger">Aranjör</string> + <string name="engineer">Mühendis</string> + <string name="mixer">Karıştırıcı</string> + <string name="creator">Yaratıcı</string> + <string name="artist">Sanatçı</string> + <string name="delete_item">Bu öğeyi silmek istediğinizden emin misiniz?</string> + <string name="show_media_management">Medya yönetim seçeneklerini göster</string> + <string name="search_for">Ara %s</string> + <string name="select_all">Hepsinin seç</string> + <string name="albums">Albümler</string> + <string name="artists">Sanatçılar</string> + <string name="songs">Şarkılar</string> + <string name="go_to_artist">Sanatçıya git</string> + <string name="now_playing">Şimdi oynatılıyor</string> + <string name="instant_mix">Anlık mix</string> + <string name="go_to_album">Albüme git</string> + <string name="add_to_queue">Kuyruğa ekle</string> + <string name="album_artist">Albüm sanatçısı</string> + <string name="album">Albüm</string> + <string name="popular_songs">Popüler şarkılar</string> + <string name="play_next">Sonrakini oynat</string> + <string name="music_videos">Müzik Videoları</string> + <string name="lyrics">Şarkı sözü</string> + <string name="hide_lyrics">Şarkı sözünü gizle</string> + <string name="show_lyrics">Şarkı sözünü göster</string> + <string name="song_has_lyrics">Şarkının sözleri var mı</string> + <string name="remove_from_queue">Kuyruktan sil</string> + <string name="show_album_cover">Albüm kapağını göster</string> + <string name="show_visualizer">Görselleştiriciyi göster</string> + <string name="show_backdrop">Arka Planı göster</string> + <string name="play_as_type">Nasıl çalınsın?</string> + <string name="visualizer_rationale">Çalan sesi görselleştirmek için ses kaydetme izni gerekiyor.</string> + <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">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> + <string name="remove_continue_watching">İzlemeye devam et listesinden kaldır</string> + <string name="remove_next_up">Sıradaki bölümlerden kaldır</string> + <string name="view_removed_next_up">Sıradaki bölümlerden kaldırılan dizileri göster</string> + <string name="studios_in">Stüdyolar: %1$s</string> + <string name="last_played">Son oynatma</string> + <string name="ass_subtitle_mode_libass">libass ile doğrudan oynatma</string> + <string name="ass_subtitle_mode_exoplayer">Yerleşik ExoPlayer ile doğrudan oynatma</string> + <string name="ass_subtitle_mode_transcode">Sunucuda dönüştür ve işle</string> + <string name="ass_subtitle_playback">SSA/ASS altyazı oynatma</string> + <string name="next_up_never">Asla</string> + <string name="external_player">Harici Oynatıcı</string> + <string name="system_default">Sistem varsayılanı</string> + <string name="skip_behavior">Atlama Davranışı</string> + <string name="skip_behavior_summary">İntrolar, jenerikler vb. için</string> + <string name="show_more">%1$s daha göster</string> + <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 ea7e0323..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> @@ -432,6 +430,7 @@ <string name="clear_track_choices">Чіткий вибір доріжки</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> @@ -474,4 +473,218 @@ <string name="zoom_out">Зменшити</string> <string name="slideshow_duration">Тривалість слайд-шоу</string> <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> + <string name="favorite_items_title">Улюблені %s</string> + <plurals name="days"> + <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> + <string name="quick_connect_code">Введіть код швидкого підключення</string> + <string name="quick_connect_code_error">Код повинен складатися з 6 цифр</string> + <string name="quick_connect_success">Пристрій успішно авторизовано</string> + <string name="send_media_info_log_to_server">Надіслати інформацію про медіа на сервер</string> + <string name="no_limit">Без обмежень</string> + <string name="max_days_next_up">Максимальна кількість днів в Очікуються</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="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 b08bfeb5..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> @@ -83,7 +83,7 @@ <string name="playlists">播放列表</string> <string name="preview">预览</string> <string name="profile_specific_settings">用户配置设置</string> - <string name="queue">队列</string> + <string name="queue">待播列表</string> <string name="recap">前情提要</string> <string name="recently_added_in">最近添加至 %1$s</string> <string name="recently_added">最近添加</string> @@ -117,11 +117,10 @@ <string name="subtitle_download_too_long">下载时间过长,可能需要重新开始播放</string> <string name="subtitle">字幕</string> <string name="subtitles">字幕</string> - <string name="suggestions">建议</string> + <string name="suggestions">推荐</string> <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> @@ -150,9 +151,9 @@ <string name="font">字体</string> <string name="background">背景</string> <string name="success">成功</string> - <string name="official_rating">年龄分级</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> @@ -208,14 +209,14 @@ <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">使用 libass 处理 ASS 字幕</string> <string name="direct_play_pgs">直接播放 PGS 字幕</string> <string name="downmix_stereo">始终降混为立体声</string> <string name="ffmpeg_extension_pref">使用 FFmpeg 解码模块</string> <string name="global_content_scale">默认内容比例</string> <string name="install_update">安装更新</string> <string name="installed_version">安装版本</string> - <string name="max_homepage_items">首页每行最大项目数</string> + <string name="max_homepage_items">首页行最大项目数</string> <string name="nav_drawer_pins_summary">选择默认显示的项目,其他项目将隐藏</string> <string name="nav_drawer_pins">自定义抽屉式导航栏项目</string> <string name="nav_drawer_switch_on_focus_summary_off">点击切换页面</string> @@ -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> @@ -351,6 +352,7 @@ <string name="only_forced_subtitles">仅强制字幕</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> @@ -389,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> @@ -417,4 +419,282 @@ </plurals> <string name="no_limit">无限制</string> <string name="max_days_next_up">即将播放中的最大天数</string> + <string name="quick_connect">快速连接</string> + <string name="quick_connect_summary">授权其他设备登录您的账号</string> + <string name="quick_connect_code">输入快速连接代码</string> + <string name="quick_connect_code_error">代码必须为六位数字</string> + <string name="quick_connect_success">设备授权成功</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="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">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> + <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="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="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> + <string name="aspect_ratios_square">正方形 (1:1)</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 密钥]]></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="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="background_style_wrap">环绕式</string> + <string name="background_style_boxed">框式</string> + <string name="series_continueing">当前</string> + <plurals name="minutes"> + <item quantity="other">%s 分钟</item> + </plurals> + <string name="animate">动画</string> + <string name="start_after">启动时间</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="in_app_screensaver">使用应用内屏幕保护程序</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="show_media_management">显示媒体管理选项</string> + <string name="delete_item">是否确定要删除此项目?</string> + <string name="search_for">搜索 %s</string> + <string name="select_all">全选</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="remove_from_queue">从待播列表中移除</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="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="view_more">查看更多</string> + <string name="discover_tv">发现电视节目</string> + <string name="discover_movies">发现电影</string> + <string name="remove_continue_watching">从继续观看中移除</string> + <string name="remove_next_up">从即将播放中移除剧集</string> + <string name="view_removed_next_up">查看已从即将播放中移除的剧集</string> + <string name="studios_in">%1$s 制片公司</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="next_up_never">从不</string> + <string name="last_played">上次播放</string> + <string name="external_player">外部播放器</string> + <string name="system_default">系统默认</string> + <string name="show_more">显示更多 %1$s 条</string> + <string name="skip_behavior">跳过行为</string> + <string name="skip_behavior_summary">适用于片头、片尾等</string> + <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 330f5dd0..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> @@ -25,13 +25,13 @@ <string name="playlists">播放清單</string> <string name="preview">預覽</string> <string name="profile_specific_settings">個人化設定</string> - <string name="queue">佇列</string> + <string name="queue">待播佇列</string> <string name="recently_added">最近新增</string> <string name="recently_recorded">最近錄製</string> <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,14 +52,13 @@ <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> <string name="tv_guide">指南</string> <string name="updates">更新</string> <string name="version">版本</string> - <string name="video_scale">畫面比例</string> + <string name="video_scale">畫面縮放比例</string> <string name="video">影片</string> <string name="videos">影片</string> <string name="watch_live">觀看直播</string> @@ -101,10 +100,10 @@ <string name="auto_check_for_updates">自動檢查更新</string> <string name="auto_play_next">自動播放下一集</string> <string name="check_for_updates">檢查更新</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="ffmpeg_extension_pref">使用 FFmpeg 解碼模組</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="max_homepage_items">首頁每列項目上限</string> @@ -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> @@ -199,12 +198,14 @@ <string name="remove_favorite">移出我的最愛</string> <string name="resume">繼續播放</string> <string name="search_and_download"><![CDATA[搜尋並下載]]></string> - <string name="studios">製片公司</string> + <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> @@ -349,6 +350,7 @@ <string name="no_trailers">無預告片</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> @@ -362,7 +364,7 @@ <string name="remove_seerr_server">移除 Seerr 伺服器</string> <string name="seerr_server_added">已新增 Seerr 伺服器</string> <string name="password">密碼</string> - <string name="username">使用者名稱</string> + <string name="username">帳號</string> <string name="url">URL</string> <string name="trending">當前趨勢</string> <string name="upcoming_movies">即將上映的電影</string> @@ -411,4 +413,320 @@ <string name="play_videos_during_slideshow">幻燈片播放時播放影片</string> <string name="image_subtitle_opacity">圖形字幕不透明度</string> <string name="brightness">亮度</string> + <plurals name="days"> + <item quantity="other">%s 天</item> + </plurals> + <string name="send_media_info_log_to_server">將媒體訊息日誌傳送到伺服器</string> + <string name="no_limit">無限制</string> + <string name="max_days_next_up">接下來播放的保留天數</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="genres_in">%1$s 類型</string> + <string name="recently_released_in">最近發行於 %1$s</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="add_row">新增一列</string> + <string name="apply_all_rows">套用到所有列</string> + <string name="home_rows">首頁各列</string> + <string name="add_row_for">為 %1$s 新增列項目</string> + <string name="display_presets_description">快速設定所有項目為內建預設樣式</string> + <string name="customize_home_summary">選擇首頁要顯示的項目及圖片樣式</string> + <string name="height">高度</string> + <string name="customize_home">自訂首頁</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="overwrite_server_settings">覆蓋伺服器上的設定?</string> + <string name="overwrite_local_settings">覆蓋本地設定?</string> + <string name="settings_saved">設定已儲存</string> + <string name="display_presets">顯示預設樣式</string> + <string name="for_episodes">針對單集</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="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="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="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</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> + <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="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">Seerr 帳號</string> + <string name="search_and_download_subtitles"><![CDATA[搜尋和下載字幕]]></string> + <string name="no_subtitles_found">未找到遠端字幕</string> + <string name="series_continueing">至今</string> + <plurals name="minutes"> + <item quantity="other">%s 分鐘</item> + </plurals> + <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="in_app_screensaver">使用內建螢幕保護程式</string> + <string name="guest_star">客串演員</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="engineer">錄音師</string> + <string name="mixer">混音師</string> + <string name="creator">創作者</string> + <string name="artist">演出者</string> + <string name="delete_item">確定要刪除此項目嗎?</string> + <string name="show_media_management">顯示媒體管理選項</string> + <string name="search_for">搜尋 %s</string> + <string name="select_all">全選</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="go_to_album">前往專輯</string> + <string name="add_to_queue">加入待播佇列</string> + <string name="remove_from_queue">移出待播佇列</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="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="instant_mix">即時混播</string> + <string name="separate_types">依類型分開顯示</string> + <string name="prefer_logos">優先顯示圖像標題</string> + <string name="updated_toast">Wolfskin 已更新至 %s</string> + <string name="view_more">查看更多</string> + <string name="discover_tv">探索電視劇</string> + <string name="discover_movies">探索電影</string> + <string name="remove_continue_watching">從繼續觀看中移除</string> + <string name="remove_next_up">從接下來播放中移除劇集</string> + <string name="view_removed_next_up">查看從接下來播放中移除的劇集</string> + <string name="studios_in">%1$s 製作公司</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="last_played">上次播放</string> + <string name="next_up_never">不顯示</string> + <string name="external_player">外部播放器</string> + <string name="system_default">系統預設</string> + <string name="skip_behavior">跳過行為</string> + <string name="skip_behavior_summary">用於片頭、片尾等段落</string> + <string name="show_more">顯示更多 (%1$s 筆)</string> + <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 371560a1..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> @@ -51,4 +54,12 @@ <string name="fa_clock" translatable="false"></string> <string name="fa_bell" translatable="false"></string> <string name="fa_check" translatable="false"></string> + <string name="fa_cloud_arrow_up" translatable="false"></string> + <string name="fa_cloud_arrow_down" translatable="false"></string> + <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 2dbad4f1..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,18 +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="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> @@ -275,6 +296,11 @@ <item quantity="one">%d second</item> <item quantity="other">%d seconds</item> </plurals> + <plurals name="minutes"> + <item quantity="zero">%s minutes</item> + <item quantity="one">%s minute</item> + <item quantity="other">%s minutes</item> + </plurals> <plurals name="movies"> <item quantity="zero">Movies</item> @@ -306,7 +332,7 @@ <string name="check_for_updates">Check for updates</string> <string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Direct play ASS subtitles</string> + <string name="direct_play_ass">Use libass for ASS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="downmix_stereo">Always downmix to stereo</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> @@ -380,11 +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="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> @@ -438,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> @@ -445,12 +495,17 @@ <string name="clear_track_choices">Clear track choices</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">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> @@ -458,6 +513,11 @@ <string name="seerr_integration">Seerr integration</string> <string name="remove_seerr_server">Remove Seerr Server</string> <string name="seerr_server_added">Seerr server added</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Authorize another device to log in to your account</string> + <string name="quick_connect_code">Enter Quick Connect code</string> + <string name="quick_connect_code_error">Code must be 6 digits</string> + <string name="quick_connect_success">Device authorized successfully</string> <string name="password">Password</string> <string name="username">Username</string> <string name="url">URL</string> @@ -471,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> @@ -492,105 +552,439 @@ <string name="send_media_info_log_to_server">Send media info log to server</string> <string name="no_limit">No limit</string> <string name="max_days_next_up">Max days in Next Up</string> + <string name="albums">Albums</string> + <string name="artists">Artists</string> + <string name="songs">Songs</string> + <string name="go_to_artist">Go to artist</string> + <string name="now_playing">Now playing</string> + <string name="instant_mix">Instant mix</string> + <string name="go_to_album">Go to album</string> + <string name="add_to_queue">Add to queue</string> + <string name="add_row">Add row</string> + <string name="genres_in">Genres in %1$s</string> + <string name="recently_released_in">Recently released in %1$s</string> + <string name="height">Height</string> + <string name="apply_all_rows">Apply to all rows</string> + <string name="customize_home">Customize home page</string> + <string name="home_rows">Home rows</string> + <string name="load_from_server">Load from server user profile</string> + <string name="save_to_server">Save to server user profile</string> + <string name="load_from_web_client">Load from web client</string> + <string name="use_series">Use series image</string> + <string name="add_row_for">Add row for %1$s</string> + <string name="overwrite_server_settings">Overwrite settings on server?</string> + <string name="overwrite_local_settings">Overwrite local settings?</string> + <string name="for_episodes">For episodes</string> + <string name="suggestions_for">Suggestions for %1$s</string> + <string name="increase_all_cards_size">Increase size for all cards</string> + <string name="decrease_all_cards_size">Decrease size for all cards</string> + <string name="use_thumb_images">Use thumb images</string> + <string name="settings_saved">Settings saved</string> + <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> + <string name="no_max">No max</string> + <string name="for_all_ages">For all ages</string> + <string name="up_to_age">Up to age %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Screensaver settings</string> + <string name="start_screensaver">Start screensaver</string> + <string name="duration">Duration</string> + <string name="photos">Photos</string> + <string name="include_types">Include types</string> + <string name="include_types_summary">What types of items to show images for</string> + <string name="content_scale_fit">Fit</string> + <string name="content_scale_crop">Crop</string> + <string name="content_scale_fill">Fill</string> + <string name="content_scale_fill_width">Fill width</string> + <string name="content_scale_fill_height">Fill height</string> + <string name="display_preset_default">Wholphin Default</string> + <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_series_thumb">Series Thumb images</string> + <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> + + <string name="volume_lowest">Lowest</string> + <string name="volume_low">Low</string> + <string name="volume_medium">Medium</string> + <string name="volume_high">High</string> + <string name="volume_full">Full</string> <string-array name="theme_song_volume"> - <item>Disabled</item> - <item>Lowest</item> - <item>Low</item> - <item>Medium</item> - <item>High</item> - <item>Full</item> + <item>@string/disabled</item> + <item>@string/volume_lowest</item> + <item>@string/volume_low</item> + <item>@string/volume_medium</item> + <item>@string/volume_high</item> + <item>@string/volume_full</item> </string-array> + <string name="purple">Purple</string> + <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>Purple</item> - <item>Blue</item> - <item>Green</item> - <item>Orange</item> - <item>Bold Blue</item> - <item>Black</item> + <item>@string/purple</item> + <item>@string/blue</item> + <item>@string/green</item> + <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> + <string name="skip_automatically">Skip automatically</string> + <string name="skip_ask">Ask to skip</string> <string-array name="skip_behaviors"> - <item>Ignore</item> - <item>Skip automatically</item> - <item>Ask to skip</item> + <item>@string/skip_ignore</item> + <item>@string/skip_automatically</item> + <item>@string/skip_ask</item> </string-array> <string-array name="content_scale"> - <item>Fit</item> - <item>None</item> - <item>Crop</item> - <item>Fill</item> - <item>Fill Width</item> - <item>Fill Height</item> + <item>@string/content_scale_fit</item> + <item>@string/none</item> + <item>@string/content_scale_crop</item> + <item>@string/content_scale_fill</item> + <item>@string/content_scale_fill_width</item> + <item>@string/content_scale_fill_height</item> </string-array> + <string name="ffmpeg_fallback">Only use FFmpeg if no built-in decoder exists</string> + <string name="ffmpeg_prefer">Prefer to use FFmpeg over built-in decoders</string> + <string name="ffmpeg_never">Never use FFmpeg decoders</string> <string-array name="ffmpeg_extension_options"> - <item>Only use FFmpeg if no built-in decoder exists</item> - <item>Prefer to use FFmpeg over built-in decoders</item> - <item>Never use FFmpeg decoders</item> + <item>@string/ffmpeg_fallback</item> + <item>@string/ffmpeg_prefer</item> + <item>@string/ffmpeg_never</item> </string-array> + <string name="next_up_playback_end">At the end of playback</string> + <string name="next_up_outro">During end credits/outro</string> + <string name="next_up_never">Never</string> <string-array name="show_next_up_when_options"> - <item>At the end of playback</item> - <item>During end credits/outro</item> + <item>@string/next_up_playback_end</item> + <item>@string/next_up_outro</item> + <item>@string/next_up_never</item> </string-array> + <string name="white">White</string> + <string name="light_gray">Light gray</string> + <string name="dark_gray">Dark gray</string> + <string name="yellow">Yellow</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> <string-array name="font_colors"> - <item>White</item> - <item>Black</item> - <item>Light Gray</item> - <item>Dark Gray</item> - <item>Red</item> - <item>Yellow</item> - <item>Green</item> - <item>Cyan</item> - <item>Blue</item> - <item>Magenta</item> + <item>@string/white</item> + <item>@string/black</item> + <item>@string/light_gray</item> + <item>@string/dark_gray</item> + <item>@string/red</item> + <item>@string/yellow</item> + <item>@string/green</item> + <item>@string/cyan</item> + <item>@string/blue</item> + <item>@string/magenta</item> </string-array> + <string name="subtitle_edge_outline">Outline</string> + <string name="subtitle_edge_shadow">Shadow</string> <string-array name="subtitle_edge"> - <item>None</item> - <item>Outline</item> - <item>Shadow</item> + <item>@string/none</item> + <item>@string/subtitle_edge_outline</item> + <item>@string/subtitle_edge_shadow</item> </string-array> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> <string-array name="background_style"> - <item>None</item> - <item>Wrap</item> - <item>Boxed</item> + <item>@string/none</item> + <item>@string/background_style_wrap</item> + <item>@string/background_style_boxed</item> </string-array> + <string name="exoplayer" translatable="false">ExoPlayer</string> + <string name="mpv" translatable="false">MPV</string> + <string name="prefer_mpv">Prefer MPV</string> + <string name="external_player">External Player</string> <string-array name="player_backend_options"> - <item>ExoPlayer</item> - <item>MPV</item> - <item>Prefer MPV</item> + <item>@string/exoplayer</item> + <item>@string/mpv</item> + <item>@string/prefer_mpv</item> + <item>@string/external_player</item> </string-array> + <string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string> <string-array name="player_backend_options_subtitles"> - <item /> - <item /> - <item>Use ExoPlayer for HDR playback</item> + <item /><!-- Intentionally blank --> + <item /><!-- Intentionally blank --> + <item>@string/player_backend_options_subtitles_prefer_mpv</item> + <item /><!-- Intentionally blank --> </string-array> + <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">Square (1:1)</string> <string-array name="aspect_ratios"> - <item>Poster (2:3)</item> - <item>16:9</item> - <item>4:3</item> - <item>Square (1:1)</item> + <item>@string/aspect_ratios_poster</item> + <item>@string/aspect_ratios_16_9</item> + <item>@string/aspect_ratios_4_3</item> + <item>@string/aspect_ratios_square</item> </string-array> + <string name="image_type_primary">Primary</string> + <string name="image_type_thumb">Thumb</string> <string-array name="image_types"> - <item>Primary</item> - <item>Thumb</item> + <item>@string/image_type_primary</item> + <item>@string/image_type_thumb</item> <!-- <item>Banner</item>--> </string-array> - - <string-array name="backdrop_style_options"> - <item>Image with dynamic color</item> - <item>Image only</item> - <item>None</item> + <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> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Login to Seerr server</string> + <string name="seerr_jellyfin_user">Jellyfin user</string> + <string name="seerr_local_user">Local user</string> + <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> + <string name="no_subtitles_found">No remote subtitles were found</string> + <string name="series_continueing">Present</string> + <string name="in_app_screensaver">Use in-app screensaver</string> + <string name="delete_item">Are you sure you want to delete this item?</string> + <string name="show_media_management">Show media management options</string> + <string-array name="backdrop_style_options"> + <item>@string/backdrop_style_dynamic</item> + <item>@string/backdrop_style_image</item> + <item>@string/none</item> + </string-array> + + <string-array name="screensaver_item_types"> + <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> + <string name="composer">Composer</string> + <string name="writer">Writer</string> + <string name="guest_star">Guest star</string> + <string name="producer">Producer</string> + <string name="conductor">Conductor</string> + <string name="lyricist">Lyricist</string> + <string name="arranger">Arranger</string> + <string name="engineer">Engineer</string> + <string name="mixer">Mixer</string> + <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> + <string name="music_videos">Music videos</string> + <string name="lyrics">Lyrics</string> + <string name="hide_lyrics">Hide lyrics</string> + <string name="show_lyrics">Show lyrics</string> + <string name="song_has_lyrics">Song has lyrics</string> + <string name="remove_from_queue">Remove from queue</string> + <string name="show_album_cover">Show album cover</string> + <string name="show_visualizer">Show visualizer</string> + <string name="show_backdrop">Show backdrop</string> + <string name="play_as_type">Play as?</string> + <string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string> + <string name="continue_string">Continue</string> + <string name="select_all">Select all</string> + <string name="separate_types">Separate types</string> + <string name="prefer_logos">Prefer showing logos for titles</string> + <string name="updated_toast">Wholphin updated to %s</string> + <string name="remove_continue_watching">Remove from continue watching</string> + <string name="remove_next_up">Remove series from next up</string> + <string name="view_removed_next_up">View series removed from next up</string> + <string name="view_more">View more</string> + <string name="discover_tv">Discover TV Shows</string> + <string name="discover_movies">Discover Movies</string> + <string name="studios_in">Studios in %1$s</string> + <string name="last_played">Last played</string> + <string name="system_default">System default</string> + <string name="ass_subtitle_mode_libass">Direct play with libass</string> + <string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string> + <string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string> + <string name="ass_subtitle_playback">SSA/ASS subtitle playback</string> + <string name="cinema_mode">Cinema mode</string> + <string name="show_more">Show %1$s more</string> + <string name="skip_behavior">Skip behavior</string> + <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> + <item>@string/ass_subtitle_mode_transcode</item> + </array> + <array name="ass_subtitle_modes_summary"> + <item>@string/default_track</item> + <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 d619d042..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 @@ -711,6 +710,8 @@ components: type: boolean series4kEnabled: type: boolean + cacheImages: + type: boolean MovieResult: type: object required: @@ -1086,6 +1087,74 @@ components: type: array items: $ref: '#/components/schemas/Episode' + status: + type: integer + example: 0 + 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: @@ -1191,8 +1260,6 @@ components: type: array items: $ref: '#/components/schemas/Season' - status: - type: string tagline: type: string type: @@ -1268,6 +1335,10 @@ components: type: string type: type: string + seasons: + type: array + items: + $ref: '#/components/schemas/Season' required: - id - status @@ -1288,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 @@ -1385,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: @@ -6135,23 +6223,17 @@ paths: type: integer example: 123 seasons: - # oneOf: - # - type: array - # items: - # type: integer - # minimum: 0 - # - type: string - # enum: [all] - # TODO - type: string - enum: [all] + type: array + items: + type: integer + minimum: 0 nullable: true is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6161,6 +6243,10 @@ paths: userid: type: integer nullable: true + tags: + type: array + items: + type: integer required: - mediaType - mediaId @@ -6256,9 +6342,9 @@ paths: is4k: type: boolean example: false - serverid: + serverId: type: integer - profileid: + profileId: type: integer rootFolder: type: string @@ -6497,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 @@ -6542,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: @@ -6559,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 @@ -6616,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 @@ -6742,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 @@ -7056,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 @@ -7096,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 new file mode 100644 index 00000000..d4590cf7 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -0,0 +1,187 @@ +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) + +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.DisplayPreferencesDto +import org.jellyfin.sdk.model.api.ScrollDirection +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserItemDataDto +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.time.LocalDateTime +import java.util.UUID + +class LatestNextUpServiceTests { + private val testDispatcher = StandardTestDispatcher() + + private val userId = UUID.randomUUID() + private val seriesId1 = UUID.randomUUID() + private val seriesId2 = UUID.randomUUID() + private val seriesId3 = UUID.randomUUID() + + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockTvShowsApi = mockk<TvShowsApi>() + private val mockDatePlayedService = mockk<DatePlayedService>() + private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>() + private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true) + + private val latestNextUpService = + LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + @Test + fun `Test nothing is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns + buildRemoved() + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(3, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId1 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId2 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3))) + } + + @Test + fun `Test seriesId1 and seriesId2 are filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved( + seriesId1 to LocalDateTime.now().minusDays(1), + seriesId2 to LocalDateTime.now().minusDays(1), + ) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(1, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3))) + } + + fun buildRemoved(vararg values: Pair<UUID, LocalDateTime>): DisplayPreferencesDto = + testDisplayPreferencesDto.copy( + customPrefs = + mutableMapOf<String, String?>().apply { + val str = Json.encodeToString(RemovedSeriesIds(values.toMap())) + put(LatestNextUpService.REMOVED_KEY, str) + }, + ) + + private val testUserItemDataDto = + UserItemDataDto( + playbackPositionTicks = 0L, + playCount = 0, + isFavorite = false, + lastPlayedDate = null, + played = false, + key = "", + itemId = UUID.randomUUID(), + ) + + private val mockResponse: Response<BaseItemDtoQueryResult> = + Response<BaseItemDtoQueryResult>( + content = + BaseItemDtoQueryResult( + items = + listOf( + mockk<BaseItemDto>(relaxed = true) { + every { seriesId } returns seriesId1 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7)) + }, + mockk<BaseItemDto>(relaxed = true) { + every { seriesId } returns seriesId2 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null) + }, + mockk<BaseItemDto>(relaxed = true) { + every { seriesId } returns seriesId3 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7)) + }, + ), + totalRecordCount = 3, + startIndex = 0, + ), + status = 200, + headers = mapOf(), + ) +} + +val testDisplayPreferencesDto = + DisplayPreferencesDto( + id = "default", + viewType = null, + sortBy = null, + indexBy = null, + rememberIndexing = false, + primaryImageHeight = 0, + primaryImageWidth = 0, + customPrefs = mapOf(), + scrollDirection = ScrollDirection.VERTICAL, + showBackdrop = false, + rememberSorting = false, + sortOrder = SortOrder.ASCENDING, + showSidebar = false, + client = null, + ) 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 e1663543..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,23 +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 kotlinx.coroutines.Dispatchers +import io.mockk.verify 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 @@ -45,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) } @@ -62,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( @@ -80,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() @@ -94,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>(), + ) + } } } @@ -119,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() @@ -138,16 +163,103 @@ 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 + fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUserFlow } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) + + 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_ignores_periodic_work_whenCacheMissing() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) + + every { mockServerRepository.currentUserFlow } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + 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.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } } @Test @@ -156,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 { @@ -190,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 { @@ -211,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() @@ -227,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 { @@ -243,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 7da1d63c..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,24 +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 @@ -34,37 +35,46 @@ 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 mockCache = mockk<SuggestionsCache>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) @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, - cache = mockCache, workManager = mockWorkManager, - ).also { it.dispatcher = testDispatcher } + dispatcher = testDispatcher, + ).also { + it.initialDelaySecondsProvider = { 60L } + } + + private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { + @Suppress("UNCHECKED_CAST") + val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() + every { future.get() } returns infos + every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future + } @Test fun schedules_periodic_work_when_user_present() = 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), @@ -76,14 +86,15 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = 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) } } @@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -101,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), @@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -125,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), @@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest { verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } + + @Test + fun does_not_schedule_if_already_scheduled_for_same_user() = + runTest { + val userId = UUID.randomUUID() + val workInfo = mockk<androidx.work.WorkInfo>() + every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED + every { workInfo.tags } returns setOf("user:$userId") + mockWorkInfos(listOf(workInfo)) + + createService() + 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), + ) + advanceUntilIdle() + + verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } } 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 7d633d87..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 @@ -46,6 +48,7 @@ class SuggestionsWorkerTest { every { mockApi.userViewsApi } returns mockUserViewsApi every { mockApi.baseUrl } returns "http://localhost" every { mockApi.accessToken } returns "test-token" + coEvery { mockCache.get(any(), any(), any()) } returns null } @After @@ -54,6 +57,8 @@ class SuggestionsWorkerTest { private fun createWorker( userId: UUID? = testUserId, serverId: UUID? = testServerId, + parentId: UUID? = null, + itemKind: BaseItemKind? = null, ): SuggestionsWorker { val inputData = Data @@ -61,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( @@ -89,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 { @@ -96,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 { @@ -135,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 { @@ -226,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/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt new file mode 100644 index 00000000..b0a8810f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString +import org.junit.Assert +import org.junit.Test + +class FormattingTests { + @Test + fun testResolutionStrings() { + Assert.assertEquals("4K", resolutionString(3840, 2160, false)) + Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) + Assert.assertEquals("720p", resolutionString(1280, 720, false)) + Assert.assertEquals("480i", resolutionString(640, 480, true)) + + Assert.assertEquals("576p", resolutionString(1024, 576, false)) + Assert.assertEquals("576p", resolutionString(960, 576, false)) + + // 21:9 + Assert.assertEquals("1080p", resolutionString(1920, 822, false)) + + Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt index 0e9bb92e..c5c2ffee 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -1,13 +1,15 @@ package com.github.damontecres.wholphin.test -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DisplayPreferencesService +import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.mockQueryResult +import com.github.damontecres.wholphin.services.testDisplayPreferencesDto import io.mockk.CapturingSlot import io.mockk.coEvery import io.mockk.every @@ -33,15 +35,28 @@ class NextUpTest { private val mockTvShowsApi = mockk<TvShowsApi>() private val mockApi = mockk<ApiClient>(relaxed = true) - private val mockContext = mockk<Context>() private val mockDatePlayedService = mockk<DatePlayedService>() + private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>() + private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true) private val latestNextUpService = - LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + LatestNextUpService( + mockApi, + mockDatePlayedService, + mockDisplayPreferencesService, + mockFavoriteWatchManager, + ) @Before fun setUp() { every { mockApi.tvShowsApi } returns mockTvShowsApi + coEvery { + mockDisplayPreferencesService.getDisplayPreferences( + any(), + any(), + any(), + ) + } returns testDisplayPreferencesDto } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt index 7af1bfce..d7e4abfd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -15,7 +15,25 @@ class TestDisplayModeChoice { val UHD_30 = DisplayMode(4, 3840, 2160, 30f) val UHD_24 = DisplayMode(5, 3840, 2160, 24f) - val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + val HD720_60 = DisplayMode(6, 1280, 720, 60f) + val HD720_30 = DisplayMode(7, 1280, 720, 30f) + val HD720_24 = DisplayMode(8, 1280, 720, 24f) + + val ALL_MODES = + listOf( + UHD_24, + UHD_30, + UHD_60, + HD_24, + HD_30, + HD_60, + HD720_60, + HD720_30, + HD720_24, + ).sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) } @Test @@ -32,7 +50,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(3, result?.modeId) + Assert.assertEquals(UHD_60.modeId, result?.modeId) } @Test @@ -49,7 +67,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = true, ) - Assert.assertEquals(0, result?.modeId) + Assert.assertEquals(HD_60.modeId, result?.modeId) } @Test @@ -66,7 +84,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(4, result?.modeId) + Assert.assertEquals(UHD_30.modeId, result?.modeId) } @Test @@ -83,14 +101,14 @@ class TestDisplayModeChoice { refreshRateSwitch = false, resolutionSwitch = true, ) - Assert.assertEquals(1, result?.modeId) + Assert.assertEquals(HD_30.modeId, result?.modeId) } @Test fun testFraction() { val streamWidth = 1920 val streamHeight = 1080 - val streamRealFrameRate = 30f + val streamRealFrameRate = 29.970f val displayModes = listOf( @@ -104,7 +122,7 @@ class TestDisplayModeChoice { displayModes = displayModes, streamWidth = streamWidth, streamHeight = streamHeight, - targetFrameRate = 29.970f, + targetFrameRate = streamRealFrameRate, refreshRateSwitch = true, resolutionSwitch = false, ) @@ -119,6 +137,136 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(1, result2?.modeId) + Assert.assertEquals(HD_30.modeId, result2?.modeId) + } + + private fun test( + expected: DisplayMode, + streamWidth: Int, + streamHeight: Int, + streamRealFrameRate: Float, + ) { + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals( + "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", + expected.modeId, + result?.modeId, + ) + } + + @Test + fun `Test choose best resolution`() { + test(HD720_30, 1280, 720, 30f) + test(HD720_30, 1280, 548, 30f) + test(HD720_30, 640, 480, 30f) + test(HD720_30, 960, 720, 30f) + test(HD720_24, 960, 720, 24f) + } + + @Test + fun `Test 60fps for 24 without resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + UHD_60, + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(UHD_60.modeId, result?.modeId) + } + + @Test + fun `Test 60fps for 24 with resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_60.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 24f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_24, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_24.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution2`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 30f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_30, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_30.modeId, result?.modeId) } } 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 new file mode 100644 index 00000000..a6fe6e2a --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -0,0 +1,162 @@ +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 +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import io.mockk.mockk +import kotlinx.serialization.json.Json +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.junit.Assert +import org.junit.Test +import kotlin.reflect.KClass + +class TestHomeRowSamples { + companion object { + val SAMPLES = + listOf( + HomeRowConfig.RecentlyAdded( + parentId = UUID.randomUUID(), + viewOptions = + HomeRowViewOptions( + heightDp = 100, + spacing = 8, + contentScale = PrefContentScale.CROP, + aspectRatio = AspectRatio.FOUR_THREE, + imageType = ViewOptionImageType.THUMB, + showTitles = false, + useSeries = false, + ), + ), + HomeRowConfig.RecentlyReleased( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Genres( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Studios(parentId = UUID.randomUUID()), + HomeRowConfig.ContinueWatching( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.NextUp( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatchingCombined( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ByParent( + parentId = UUID.randomUUID(), + recursive = true, + sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.GetItems( + name = "Episodes by date created", + filter = + HomeGetItemsFilter( + parentId = UUID.randomUUID(), + recursive = true, + isFavorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), + HomeRowConfig.Recordings(), + 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")), + ), + ) + } + + @Test + fun `Check all types have a sample`() { + // This ensures there is a sample for each possible HomeRowConfig type + val foundTypes = mutableSetOf<KClass<out HomeRowConfig>>() + SAMPLES.forEach { + when (it) { + is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class) + is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class) + is HomeRowConfig.Genres -> foundTypes.add(it::class) + is HomeRowConfig.NextUp -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class) + is HomeRowConfig.ByParent -> foundTypes.add(it::class) + is HomeRowConfig.GetItems -> foundTypes.add(it::class) + is HomeRowConfig.Favorite -> foundTypes.add(it::class) + is HomeRowConfig.Recordings -> foundTypes.add(it::class) + is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) + 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) + } + + @Test + fun `Print sample JSON`() { + // This just prints out the JSON of the samples so developers can review + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + } + val string = json.encodeToString(SAMPLES) + println(string) + json.decodeFromString<List<HomeRowConfig>>(string) + } + + @Test + fun `Parse list of rows with an unknown type`() { + val service = + HomeSettingsService( + context = mockk(), + api = mockk(), + serverRepository = mockk(), + navDrawerService = mockk(), + displayPreferencesService = mockk(), + serverPluginApi = mockk(), + serverDao = mockk(), + ) + + val str = """{ + "type": "HomePageSettings", + "version": 1, + "rows": [ + { + "type": "RecentlyAdded", + "parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf" + }, + { + "type": "Does not exist", + "viewOptions": {} + } + ] + }""" + + 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/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt index 76893f8c..c19ffb81 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.ui.setup.seerr.createUrls -import org.junit.Assert +import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl +import org.junit.Assert.assertEquals import org.junit.Test class TestSeerr { @@ -13,12 +15,12 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "https://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "https://jellyseerr.com/", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -29,10 +31,10 @@ class TestSeerr { val expected = listOf( - "https://jellyseerr.com/api/v1", - "https://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -43,10 +45,10 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "http://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -57,9 +59,115 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls5() { + val urls = + createUrls("10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:443/", + "https://10.0.0.2/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls6() { + val urls = + createUrls("10.0.0.2:8080") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:8080/", + "https://10.0.0.2:8080/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls7() { + val urls = + createUrls("http://10.0.0.2:80") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2/", + "http://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls8() { + val urls = + createUrls("https://10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "https://10.0.0.2/", + "https://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test createUrls for path`() { + val urls = + createUrls("https://jellyseerr.com/seerr/") + .map { it.toString() } + + val expected = + listOf( + "https://jellyseerr.com/seerr/", + "https://jellyseerr.com:5055/seerr/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test build api url`() { + var url = "https://jellyseerr.com/" + assertEquals("https://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "https://jellyseerr.com/path" + assertEquals("https://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:5055/" + assertEquals("http://jellyseerr.com:5055/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:7878/path/" + assertEquals("http://jellyseerr.com:7878/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1" + assertEquals("http://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1/" + assertEquals("http://jellyseerr.com/api/v1/", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/path/api/v1" + assertEquals("http://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + } + + @Test + fun `Test migration`() { + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1")) + assertEquals("https://10.0.0.2/", migrateSeerrUrl("https://10.0.0.2/api/v1")) + assertEquals("http://10.0.0.2:5055/", migrateSeerrUrl("http://10.0.0.2:5055/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1/")) + assertEquals("http://10.0.0.2/path/", migrateSeerrUrl("http://10.0.0.2/path/api/v1")) + assertEquals("http://10.0.0.2/api/v1/", migrateSeerrUrl("http://10.0.0.2/api/v1/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2")) } } 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 new file mode 100644 index 00000000..2e84e2ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -0,0 +1,174 @@ +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 +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestUpdateChecker { + lateinit var releaseJson: JsonObject + val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } + + 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() + 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 does not choose upstream Wholphin assets`() { + val url = getDownloadUrl(assetsJson, false, listOf()) + Assert.assertNull(url) + } + + @Test + fun `Choose abi does not choose upstream Wholphin assets`() { + val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) + Assert.assertNull(url) + } + + @Test + fun `Choose unknown abi does not choose upstream Wholphin assets`() { + val url = getDownloadUrl(assetsJson, false, listOf("unknown")) + Assert.assertNull(url) + } + + @Test + fun `Debug does not choose upstream Wholphin assets`() { + val url = getDownloadUrl(assetsJson, true, listOf()) + Assert.assertNull(url) + } + + @Test + fun `Choose debug abi does not choose upstream Wholphin assets`() { + val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) + 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 new file mode 100644 index 00000000..2e2c3a89 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,264 @@ +package com.github.damontecres.wholphin.ui + +import android.app.Application +import android.content.pm.ActivityInfo +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused +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.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + // From https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = + object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + val activityInfo = + ActivityInfo().apply { + name = TestActivity::class.java.name + packageName = appContext.packageName + } + shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo) + } + } + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + WholphinDispatchers.configure(TestModule.testDispatcher) + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + WholphinDispatchers.reset() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.state.value.addServerState.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.state.value.addServerState is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} 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 new file mode 100644 index 00000000..b266bb96 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,298 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +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 +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +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.WholphinDispatchers +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() + + @Provides + @Singleton + fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, WholphinDispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @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( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore<AppPreferences> = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore<Preferences> = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) + } +} 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 new file mode 100644 index 00000000..6d30a600 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +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() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +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..511dbaa2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -0,0 +1,276 @@ +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 + 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/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt new file mode 100644 index 00000000..6a6037f9 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeekAccelerationTest { + @Test + fun returnsOneWhenNotRepeating() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) + } + + @Test + fun unknownDurationDoesNotAccelerate() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) + } + + @Test + fun shortContentHasTwoTiers() { + val shortDurationMs = 20L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), + ) + } + + @Test + fun mediumContentEscalatesAcrossAllTiers() { + val mediumDurationMs = 60L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), + ) + assertEquals( + 3, + calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), + ) + assertEquals( + 4, + calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), + ) + } +} 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/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/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..c4e63d27 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -0,0 +1,95 @@ +// 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.read(user.id) } returns HomeLibrary(resume = listOf( + HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) + 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..b5be3c2b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -0,0 +1,105 @@ +// 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))) + assertEquals(1, requests.openCount) + } + } + } + + @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/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/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..f741223e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -0,0 +1,108 @@ +// 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 + 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 (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..dc0b780d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -0,0 +1,107 @@ +// 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.read(personA.id) } coAnswers { started.complete(Unit); response.await() } + 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) + 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.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } + coEvery { facade.read(personB.id) } throws IOException("fixture offline") + 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.read(personA.id) } returns library + 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.read(personA.id) } returns library + 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..279c3966 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -0,0 +1,70 @@ +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 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..20ea6d9b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -0,0 +1,252 @@ +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.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) + 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/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt new file mode 100644 index 00000000..92c0a248 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -0,0 +1,114 @@ +// 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.playableId(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.playableId(user.id, title) } returns id + val view = shell.view + view.cards["hero"] = 0 + rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } + awaitModel { navigation.backStack.last() is Destination.Playback } + val destination = navigation.backStack.last() as Destination.Playback + assertEquals(id, destination.itemId) + assertEquals((title.resumeSeconds * 1000).toLong(), 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, resume = true), shell, server, otherUser) } + awaitModel { model.toast != null } + assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) + assertTrue(navigation.backStack.single() is Destination.Home) + coVerify(exactly = 0) { facade.playableId(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..6df8426d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -0,0 +1,345 @@ +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.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) + } } + } + + 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) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() { + render("Andor") + assertEquals(603f, 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/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt new file mode 100644 index 00000000..fde2ff9c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -0,0 +1,239 @@ +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)) + 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) + } + + @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/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/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_develop.json b/app/src/test/resources/release_develop.json new file mode 100644 index 00000000..df48106f --- /dev/null +++ b/app/src/test/resources/release_develop.json @@ -0,0 +1,475 @@ +{ + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448", + "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448/assets", + "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/297064448/assets{?name,label}", + "html_url": "https://github.com/damontecres/Wholphin/releases/tag/develop", + "id": 297064448, + "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_kwDOPzwRps4RtNgA", + "tag_name": "develop", + "target_commitish": "main", + "name": "v0.5.3-8-g627b89f1", + "draft": false, + "immutable": false, + "prerelease": true, + "created_at": "2026-03-14T20:47:08Z", + "updated_at": "2026-03-14T20:54:31Z", + "published_at": "2026-03-14T20:54:31Z", + "assets": [ + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937404", + "id": 373937404, + "node_id": "RA_kwDOPzwRps4WSdT8", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-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": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937407", + "id": 373937407, + "node_id": "RA_kwDOPzwRps4WSdT_", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-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": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:25Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937406", + "id": 373937406, + "node_id": "RA_kwDOPzwRps4WSdT-", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44.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": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937405", + "id": 373937405, + "node_id": "RA_kwDOPzwRps4WSdT9", + "name": "Wholphin-debug-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": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937408", + "id": 373937408, + "node_id": "RA_kwDOPzwRps4WSdUA", + "name": "Wholphin-debug-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": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937422", + "id": 373937422, + "node_id": "RA_kwDOPzwRps4WSdUO", + "name": "Wholphin-debug.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": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937424", + "id": 373937424, + "node_id": "RA_kwDOPzwRps4WSdUQ", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-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": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937427", + "id": 373937427, + "node_id": "RA_kwDOPzwRps4WSdUT", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-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": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937431", + "id": 373937431, + "node_id": "RA_kwDOPzwRps4WSdUX", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44.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": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 0, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937439", + "id": 373937439, + "node_id": "RA_kwDOPzwRps4WSdUf", + "name": "Wholphin-release-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": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 1, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937452", + "id": 373937452, + "node_id": "RA_kwDOPzwRps4WSdUs", + "name": "Wholphin-release-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": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:30Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937453", + "id": 373937453, + "node_id": "RA_kwDOPzwRps4WSdUt", + "name": "Wholphin-release.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": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 1, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:31Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk" + } + ], + "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/develop", + "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/develop", + "body": "### `main` development build\n\nThis pre-release tracks the development build of Wholphin from the `main` unstable branch.\n\nSee https://github.com/damontecres/Wholphin/releases/latest for the latest stable release.\n\nIf you want to update to this version in-app, change Settings->Advanced->Update URL to https://api.github.com/repos/damontecres/Wholphin/releases/tags/develop (replace `latest` with `tags/develop`).\n\n[See changes since `v0.5.3`](https://github.com/damontecres/Wholphin/compare/v0.5.3...develop)\n" +} 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..1718dc44 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -0,0 +1,60 @@ +// 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(), +) + +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 stage = when { + stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed + visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready + awaitingApproval == true -> ComingInStage.Waiting + visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady + acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading + acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching + stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking + else -> ComingInStage.Waiting + } + return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, + requester.name, actor?.requester, actor?.name, stage) +} 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..06caea9c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -0,0 +1,164 @@ +// 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 picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } + val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> + candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } + }.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } + 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..c8ab24cc --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -0,0 +1,218 @@ +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +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(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } + if (retry && conductarr.identity.value?.signedIn != true) { + withContext(Dispatchers.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(Dispatchers.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 + 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), + 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) + } + } + try { + val library = withContext(Dispatchers.IO) { home.read(user.id) } + val savedAt = System.currentTimeMillis() + libraryCopy = library + publish() + withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision != loadRevision) return + libraryAvailable = false + librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } + publish() + if (retry) showToast(RecoveryMessage.StillUnreachable) + } + } + + 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 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 id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } + if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) + else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) + } 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() + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { 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..2c88cc9b --- /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.openCount > 0) 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..e457aa86 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -0,0 +1,333 @@ +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 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 + + 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 + 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.takeIf { it.data.status == TitleReadStatus.Ready }?.season, 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 -> navigation.navigateTo(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))) + is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) + is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) + 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?, position: Long) { + viewModelScope.launch { + try { + if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") + facade.preparePlayback(id) + navigation.navigateTo(Destination.Playback(id, position)) + } 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() + LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { 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/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..0301d3dc --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -0,0 +1,106 @@ +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 + } + LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { + 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) { 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..1867ae60 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -0,0 +1,86 @@ +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 + suspend fun read(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) } + 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(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) + } + + suspend fun playableId(userId: UUID, title: HomeTitle): UUID? { + title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> + val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content + if (item.type == BaseItemKind.SERIES) { + return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull() + ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull()) + ?.takeIf { it.type == BaseItemKind.EPISODE }?.id + } + return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE } + } + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + return 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 + } + + 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..85395ba0 --- /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, 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 && 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/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt new file mode 100644 index 00000000..4b3a3a4a --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -0,0 +1,216 @@ +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 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() }) + } 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) + } + + /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ + suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(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) }) + } + return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) }, + rows.first { it.id.toString() == watchId }.let { mapItem(it) }, 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) }, + connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) + } + + private fun mapItem(item: BaseItemDto): 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 = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, 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 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 f2004532..4ff28386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.protobuf) apply false 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/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..228da088 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,909 @@ +# 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-<abi>.apk` for the +in-app updater. Gradle outputs +`Wholphin-default-release-<versionName>-<versionCode>[-<abi>].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 under the final person 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. + +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` is the only Home code importing Jellyfin API clients. Release +variants read the selected user's Resume, Next up, newest titles and destination +shelves, with app-private last-known-good projections through the shared atomic +disk cache. A missing saved library becomes a recovery panel; a saved library +stays visible when Jellyfin is unreachable. Playback resolves an actual film or +episode and checks Jellyfin before opening upstream's player. No season, +recommendation stub or different person's session is promoted to playback. +An unresolved person switch guards Home and both owned/upstream library content +before mounting any source read with the prior person's Jellyfin session. + +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 snapshot. +Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, +Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four +with readiness expanded. Right expands Request/Download/Library/Playback; 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”. + +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. + +## 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. + +## 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. +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="<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 132244e5..6c29e7b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,14 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=false +android.sdk.defaultTargetSdkToCompileSdkIfUnset=true +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 +android.newDsl=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92c7835c..9d1d45b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,52 +1,67 @@ [versions] -aboutLibraries = "13.2.1" -acra = "5.13.1" -agp = "8.13.2" +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" -hiltCompiler = "1.3.0" -hiltNavigationCompose = "1.3.0" -hiltWork = "1.3.0" +hiltNavigationCompose = "1.4.0" +hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.5" -coreKtx = "1.17.0" -appcompat = "1.7.1" -composeBom = "2026.01.01" -mockk = "1.14.9" -robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.39.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-alpha12" -tvMaterial = "1.0.1" -lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.3" -androidx-media3 = "1.9.2" -coil = "3.3.0" +tvFoundation = "1.0.0" +tvMaterial = "1.1.0-alpha01" +lifecycleRuntimeKtx = "2.11.0" +activityCompose = "1.13.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.0.0" -lifecycleViewmodelNav3 = "2.10.0" -material3AdaptiveNav3 = "1.0.0-alpha03" -protobuf = "0.9.6" -datastore = "1.2.0" -kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.33.5" -hilt = "2.58" -room = "2.8.4" +nav3Core = "1.1.7" +lifecycleViewmodelNav3 = "2.11.0" +material3AdaptiveNav3 = "1.3.0" +protobuf = "0.10.0" +datastore = "1.2.1" +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.1" +workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" -kotlinxCoroutinesTest = "1.10.2" +libass-android = "0.5.1" +kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.19.0" +openapi-generator = "7.26.0" +runner = "1.7.0" +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" } +wholphin-extensions-ffmpeg = { module = "com.github.damontecres.wholphin.media3:decoder-ffmpeg", version.ref = "wholphin-extensions" } +wholphin-extensions-av1 = { module = "com.github.damontecres.wholphin.media3:decoder-av1", version.ref = "wholphin-extensions" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } @@ -63,27 +78,28 @@ 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-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" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } @@ -104,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" } @@ -132,10 +149,16 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } 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 = "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" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } @@ -145,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 1b33c55b..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 aaaabb3c..91c4fc64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a93..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -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: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @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 with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@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=. @@ -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. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +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. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@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 & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<lint> + <issue id="ExtraTranslation" severity="warning" /> +</lint> diff --git a/renovate.json b/renovate.json index 03e6a404..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", @@ -23,7 +30,8 @@ "groupName": "Jellyfin SDK", "matchPackageNames": [ "org.jellyfin.sdk:**" - ] + ], + "enabled": false }, { "groupName": "AGP", @@ -36,6 +44,13 @@ "matchPackageNames": [ "org.jetbrains.kotlin**" ] + }, + { + "groupName": "Internal dependencies", + "matchPackageNames": [ + "com.github.damontecres.wholphin**" + ], + "enabled": false } ] } diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md deleted file mode 100644 index d1fdac02..00000000 --- a/scripts/ffmpeg/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# FFmpeg ExoPlayer decoder extension module - -Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback. diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh deleted file mode 100755 index 1741c703..00000000 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -set -ex - -if [ -z "$1" ]; then - echo "Error: Must provide NDK path" - exit 1 -fi -NDK_PATH="$1" - -SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")" -SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")" -PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" - -# Config -ANDROID_ABI=21 -ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) -FFMPEG_BRANCH="release/6.0" -DAV1D_BRANCH="1.5.3" - -# Path configs -DIR_PATH="$(pwd)" -TARGET_PATH="$PROJECT_ROOT/app/libs" -MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" -FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" -FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" -AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" -HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" -HOST_PLATFORM="$HOST-x86_64" - -mkdir -p "$TARGET_PATH" -mkdir -p ffmpeg_decoder - -echo "$PROJECT_ROOT/gradle/libs.versions.toml" - -media_version="$(grep "androidx-media3 = " "$PROJECT_ROOT/gradle/libs.versions.toml" | awk -F'"' '{print $2}')" - -pushd ffmpeg_decoder || exit - -if [[ -d media ]]; then - pushd media || exit - git checkout --force "$media_version" -else - git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media -fi - -if [[ -d ffmpeg ]]; then - pushd ffmpeg || exit - git checkout --force "$FFMPEG_BRANCH" -else - git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg -fi - -[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" - -pushd "${FFMPEG_MODULE_PATH}/jni" || exit - -./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" - -# av1 module - -pushd "$AV1_MODULE_PATH/jni" || exit - -if [[ ! -d cpu_features ]]; then - git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features -fi - -pushd "$AV1_MODULE_PATH/jni" || exit - -if [[ -d dav1d ]]; then - pushd dav1d || exit - git checkout --force "$DAV1D_BRANCH" -else - git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d -fi - -pushd "$AV1_MODULE_PATH/jni" || exit - -/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" - - -pushd "$MEDIA_PATH" || exit -./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble -popd || exit - -popd || exit -cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" -cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/" -popd || exit diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md deleted file mode 100644 index 106907bb..00000000 --- a/scripts/mpv/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# MPV build scripts - -This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae0d956c5a98ab8bf25af7e2c73bcb59e19c15b7/buildscripts licensed MIT. - -## Instructions - -```bash -cd scripts/mpv -./get_dependencies.sh - -# Install build dependencies -pip install meson jsonschema - -export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865 -# Build arm64 -PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv -# Build arm32 -PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv - -cd ../.. -env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/ -``` diff --git a/scripts/mpv/buildall.sh b/scripts/mpv/buildall.sh deleted file mode 100755 index 56208015..00000000 --- a/scripts/mpv/buildall.sh +++ /dev/null @@ -1,180 +0,0 @@ -#!/bin/bash -e - -cd "$( dirname "${BASH_SOURCE[0]}" )" -. ./include/depinfo.sh - -cleanbuild=0 -nodeps=0 -clang=1 -target=mpv-android -arch=armv7l - -getdeps () { - varname="dep_${1//-/_}[*]" - echo ${!varname} -} - -loadarch () { - unset CC CXX CPATH LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH - unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS - - local apilvl=21 - # ndk_triple: what the toolchain actually is - # cc_triple: what Google pretends the toolchain is - if [ "$1" == "armv7l" ]; then - export ndk_suffix= - export ndk_triple=arm-linux-androideabi - cc_triple=armv7a-linux-androideabi$apilvl - prefix_name=armv7l - elif [ "$1" == "arm64" ]; then - export ndk_suffix=-arm64 - export ndk_triple=aarch64-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=arm64 - elif [ "$1" == "x86" ]; then - export ndk_suffix=-x86 - export ndk_triple=i686-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=x86 - elif [ "$1" == "x86_64" ]; then - export ndk_suffix=-x64 - export ndk_triple=x86_64-linux-android - cc_triple=$ndk_triple$apilvl - prefix_name=x86_64 - else - echo "Invalid architecture" >&2 - exit 1 - fi - export prefix_dir="$PWD/prefix/$prefix_name" - if [ $clang -eq 1 ]; then - export CC=$cc_triple-clang - export CXX=$cc_triple-clang++ - else - export CC=$cc_triple-gcc - export CXX=$cc_triple-g++ - fi - export LDFLAGS="-Wl,-O1,--icf=safe -Wl,-z,max-page-size=16384" - export AR=llvm-ar - export RANLIB=llvm-ranlib -} - -setup_prefix () { - if [ ! -d "$prefix_dir" ]; then - mkdir -p "$prefix_dir" - # enforce flat structure (/usr/local -> /) - ln -s . "$prefix_dir/usr" - ln -s . "$prefix_dir/local" - fi - - local cpu_family=${ndk_triple%%-*} - [ "$cpu_family" == "i686" ] && cpu_family=x86 - - if ! command -v pkg-config >/dev/null; then - echo "pkg-config not provided!" - return 1 - fi - - # meson wants to be spoonfed this file, so create it ahead of time - # also define: release build, static libs and no source downloads at runtime(!!!) - cat >"$prefix_dir/crossfile.tmp" <<CROSSFILE -[built-in options] -buildtype = 'release' -default_library = 'static' -wrap_mode = 'nodownload' -prefix = '/usr/local' -[binaries] -c = '$CC' -cpp = '$CXX' -ar = 'llvm-ar' -nm = 'llvm-nm' -strip = 'llvm-strip' -pkgconfig = 'pkg-config' -pkg-config = 'pkg-config' -[host_machine] -system = 'android' -cpu_family = '$cpu_family' -cpu = '${CC%%-*}' -endian = 'little' -CROSSFILE - # also avoid rewriting it needlessly - if cmp -s "$prefix_dir"/crossfile.{tmp,txt}; then - rm "$prefix_dir/crossfile.tmp" - else - mv "$prefix_dir"/crossfile.{tmp,txt} - fi -} - -build () { - if [ $1 != "mpv-android" ] && [ ! -d deps/$1 ]; then - printf >&2 '\e[1;31m%s\e[m\n' "Target $1 not found" - return 1 - fi - if [ $nodeps -eq 0 ]; then - printf >&2 '\e[1;34m%s\e[m\n' "Preparing $1..." - local deps=$(getdeps $1) - echo >&2 "Dependencies: $deps" - for dep in $deps; do - build $dep - done - fi - printf >&2 '\e[1;34m%s\e[m\n' "Building $1..." - if [ "$1" == "mpv-android" ]; then - pushd .. - BUILDSCRIPT=buildscripts/scripts/$1.sh - else - pushd deps/$1 - BUILDSCRIPT=../../scripts/$1.sh - fi - [ $cleanbuild -eq 1 ] && $BUILDSCRIPT clean - $BUILDSCRIPT build - popd -} - -usage () { - printf '%s\n' \ - "Usage: buildall.sh [options] [target]" \ - "Builds the specified target (default: $target)" \ - "-n Do not build dependencies" \ - "--clean Clean build dirs before compiling" \ - "--gcc Use gcc compiler (unsupported!)" \ - "--arch <arch> Build for specified architecture (default: $arch; supported: armv7l, arm64, x86, x86_64)" - exit 0 -} - -while [ $# -gt 0 ]; do - case "$1" in - --clean) - cleanbuild=1 - ;; - -n|--no-deps) - nodeps=1 - ;; - --gcc) - clang=0 - ;; - --arch) - shift - arch=$1 - ;; - -h|--help) - usage - ;; - -*) - echo "Unknown flag $1" >&2 - exit 1 - ;; - *) - target=$1 - ;; - esac - shift -done - -loadarch $arch -setup_prefix -build $target - -[ "$target" == "mpv-android" ] && \ - ls -lh ../app/build/outputs/apk/{default,api29}/*/*.apk - -exit 0 diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh deleted file mode 100755 index 7ca18de8..00000000 --- a/scripts/mpv/get_dependencies.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash - -set -exou pipefail - -BUILD_PATH="./deps" - -mkdir -p "$BUILD_PATH" -pushd "$BUILD_PATH" || exit - -function clone(){ - repo=$1 - branch=$2 - dir=$3 - shift 3 - - if [[ -d "$dir" ]]; then - pushd "$dir" || exit - git checkout --force "$branch" - popd || exit - else - git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@" - fi -} - -clone "https://github.com/videolan/dav1d" "1.5.3" dav1d - -clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg - -clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules - -clone "https://github.com/libass/libass" "0.17.4" libass - -clone "https://github.com/haasn/libplacebo" "v7.351.0" libplacebo --recurse-submodules - -clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv - -if [[ ! -d mbedtls ]]; then - mkdir mbedtls - wget https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 -O - | \ - tar -xj -C mbedtls --strip-components=1 -fi - -if [[ ! -d fribidi ]]; then - mkdir fribidi - wget https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz -O - | \ - tar -xJ -C fribidi --strip-components=1 -fi - -if [[ ! -d harfbuzz ]]; then - mkdir harfbuzz - wget https://github.com/harfbuzz/harfbuzz/releases/download/12.1.0/harfbuzz-12.1.0.tar.xz -O - | \ - tar -xJ -C harfbuzz --strip-components=1 -fi - -version_unibreak="6.1" -if [[ ! -d unibreak ]]; then - mkdir unibreak - wget https://github.com/adah1972/libunibreak/releases/download/libunibreak_${version_unibreak//./_}/libunibreak-${version_unibreak}.tar.gz -O - | \ - tar -xz -C unibreak --strip-components=1 -fi - -if [[ ! -d lua ]]; then - mkdir lua - wget https://www.lua.org/ftp/lua-5.2.4.tar.gz -O - | \ - tar -xz -C lua --strip-components=1 -fi - -# python packages: jsonschema jinja2 meson - -popd || exit diff --git a/scripts/mpv/include/ci.sh b/scripts/mpv/include/ci.sh deleted file mode 100755 index 19b44a0b..00000000 --- a/scripts/mpv/include/ci.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -e - -# go to buildscripts root folder -cd "$( dirname "${BASH_SOURCE[0]}" )/.." - -. ./include/depinfo.sh - -msg() { - printf '==> %s\n' "$1" -} - -fetch_prefix() { - if [[ "$CACHE_MODE" == folder ]]; then - local text= - if [ -f "$CACHE_FOLDER/id.txt" ]; then - text=$(cat "$CACHE_FOLDER/id.txt") - else - echo "Cache seems to be empty" - fi - printf 'Expecting "%s",\nfound "%s".\n' "$ci_tarball" "$text" - if [[ "$text" == "$ci_tarball" ]]; then - tar -xzf "$CACHE_FOLDER/data.tgz" -C prefix && return 0 - fi - fi - return 1 -} - -build_prefix() { - msg "Building the prefix ($ci_tarball)..." - - msg "Fetching deps" - IN_CI=1 ./include/download-deps.sh - - # build everything mpv depends on (but not mpv itself) - for x in ${dep_mpv[@]}; do - msg "Building $x" - ./buildall.sh $x - done - - if [[ "$CACHE_MODE" == folder && -w "$CACHE_FOLDER" ]]; then - msg "Compressing the prefix" - tar -cvzf "$CACHE_FOLDER/data.tgz" -C prefix . - echo "$ci_tarball" >"$CACHE_FOLDER/id.txt" - fi -} - -export WGET="wget --progress=bar:force" - -if [ "$1" = "export" ]; then - # export variable with unique cache identifier - echo "CACHE_IDENTIFIER=$ci_tarball" - exit 0 -elif [ "$1" = "install" ]; then - # install deps - if [[ -n "$ANDROID_HOME" && -d "$ANDROID_HOME" ]]; then - msg "Linking existing SDK" - mkdir -p sdk - ln -sv "$ANDROID_HOME" sdk/android-sdk-linux - fi - - msg "Fetching SDK + NDK" - IN_CI=1 ./include/download-sdk.sh - - msg "Fetching mpv" - mkdir -p deps/mpv - $WGET https://github.com/mpv-player/mpv/archive/master.tar.gz -O master.tgz - tar -xzf master.tgz -C deps/mpv --strip-components=1 - rm master.tgz - - msg "Trying to fetch existing prefix" - mkdir -p prefix - fetch_prefix || build_prefix - exit 0 -elif [ "$1" = "build" ]; then - # run build - : -else - exit 1 -fi - -msg "Building mpv" -./buildall.sh -n mpv || { - # show logfile if configure failed - [ ! -f deps/mpv/_build/config.h ] && cat deps/mpv/_build/meson-logs/meson-log.txt - exit 1 -} - -msg "Building mpv-android" -./buildall.sh -n - -exit 0 diff --git a/scripts/mpv/include/depinfo.sh b/scripts/mpv/include/depinfo.sh deleted file mode 100755 index 875e3687..00000000 --- a/scripts/mpv/include/depinfo.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -e - -## Dependency versions -# Make sure to keep v_ndk and v_ndk_n in sync, both are listed on the NDK download page - -v_sdk=11076708_latest -v_ndk=r29 -v_ndk_n=29.0.14206865 -v_sdk_platform=35 -v_sdk_build_tools=35.0.0 - -v_lua=5.2.4 -v_unibreak=6.1 -v_harfbuzz=12.1.0 -v_fribidi=1.0.16 -v_freetype=2.14.1 -v_mbedtls=3.6.4 - - -## Dependency tree -# I would've used a dict but putting arrays in a dict is not a thing - -dep_mbedtls=() -dep_dav1d=() -dep_ffmpeg=(mbedtls dav1d) -dep_freetype2=() -dep_fribidi=() -dep_harfbuzz=() -dep_unibreak=() -dep_libass=(freetype2 fribidi harfbuzz unibreak) -dep_lua=() -dep_libplacebo=() -dep_mpv=(ffmpeg libass lua libplacebo) -dep_mpv_android=(mpv) - - -## for CI workflow - -# pinned ffmpeg revision -v_ci_ffmpeg=n8.0 - -# filename used to uniquely identify a build prefix -ci_tarball="prefix-ndk-${v_ndk}-lua-${v_lua}-unibreak-${v_unibreak}-harfbuzz-${v_harfbuzz}-fribidi-${v_fribidi}-freetype-${v_freetype}-mbedtls-${v_mbedtls}-ffmpeg-${v_ci_ffmpeg}.tgz" diff --git a/scripts/mpv/include/path.sh b/scripts/mpv/include/path.sh deleted file mode 100755 index 4ab1073c..00000000 --- a/scripts/mpv/include/path.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" - -. "$DIR/include/depinfo.sh" - -os=linux -[[ "$OSTYPE" == "darwin"* ]] && os=mac -export os - -if [ "$os" == "mac" ]; then - [ -z "$cores" ] && cores=$(sysctl -n hw.ncpu) - # various things rely on GNU behaviour - export INSTALL=`which ginstall` - export SED=gsed -else - [ -z "$cores" ] && cores=$(grep -c ^processor /proc/cpuinfo) -fi -cores=${cores:-4} - -# configure pkg-config paths if inside buildscripts -if [ -n "$ndk_triple" ]; then - export PKG_CONFIG_SYSROOT_DIR="$prefix_dir" - export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/lib/pkgconfig" - unset PKG_CONFIG_PATH -fi - -toolchain=$(echo "$DIR/sdk/android-ndk-${v_ndk}/toolchains/llvm/prebuilt/"*) -[ -d "$toolchain" ] && \ - export PATH="$toolchain/bin:$DIR/sdk/android-ndk-${v_ndk}:$DIR/sdk/bin:$PATH" -export ANDROID_HOME="$DIR/sdk/android-sdk-$os" -unset ANDROID_SDK_ROOT ANDROID_NDK_ROOT diff --git a/scripts/mpv/scripts/dav1d.sh b/scripts/mpv/scripts/dav1d.sh deleted file mode 100755 index aef89de7..00000000 --- a/scripts/mpv/scripts/dav1d.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Denable_tests=false -Db_lto=true -Dstack_alignment=16 - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/ffmpeg.sh b/scripts/mpv/scripts/ffmpeg.sh deleted file mode 100755 index c9c73a74..00000000 --- a/scripts/mpv/scripts/ffmpeg.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf _build$ndk_suffix - exit 0 -else - exit 255 -fi - -mkdir -p _build$ndk_suffix -cd _build$ndk_suffix - -cpu=armv7-a -[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a -[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic -[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm" - -cpuflags= -[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8" - -args=( - --target-os=android --enable-cross-compile - --cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm - --arch=${ndk_triple%%-*} --cpu=$cpu - --extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib" - - --enable-{jni,mediacodec,mbedtls,libdav1d} --disable-vulkan - --disable-static --enable-shared --enable-{gpl,version3} - - # disable unneeded parts - --disable-{stripping,doc,programs} - # to keep the build lean we disable some feature quite aggressively: - # - muxers, encoders: mpv-android does not have any way to use these - # - devices: no practical use on Android - --disable-{muxers,encoders,devices} - # useful to taking screenshots - --enable-encoder=mjpeg,png - # useful for the `dump-cache` command - --enable-muxer=mov,matroska,mpegts -) -../configure "${args[@]}" - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/freetype2.sh b/scripts/mpv/scripts/freetype2.sh deleted file mode 100755 index deceb0b4..00000000 --- a/scripts/mpv/scripts/freetype2.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/fribidi.sh b/scripts/mpv/scripts/fribidi.sh deleted file mode 100755 index a9e22aa5..00000000 --- a/scripts/mpv/scripts/fribidi.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -D{tests,docs}=false - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/harfbuzz.sh b/scripts/mpv/scripts/harfbuzz.sh deleted file mode 100755 index c1d074f6..00000000 --- a/scripts/mpv/scripts/harfbuzz.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Dtests=disabled -Ddocs=disabled - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/libass.sh b/scripts/mpv/scripts/libass.sh deleted file mode 100755 index 739916de..00000000 --- a/scripts/mpv/scripts/libass.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf _build$ndk_suffix - exit 0 -else - exit 255 -fi - -[ -f configure ] || ./autogen.sh - -mkdir -p _build$ndk_suffix -cd _build$ndk_suffix - -../configure \ - --host=$ndk_triple --with-pic \ - --enable-static --disable-shared \ - --enable-libunibreak --disable-require-system-font-provider - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/libplacebo.sh b/scripts/mpv/scripts/libplacebo.sh deleted file mode 100755 index fac33d94..00000000 --- a/scripts/mpv/scripts/libplacebo.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - -Dvulkan=disabled -Ddemos=false - -ninja -C $build -j$cores -DESTDIR="$prefix_dir" ninja -C $build install - -# add missing library for static linking -# this isn't "-lstdc++" due to a meson bug: https://github.com/mesonbuild/meson/issues/11300 -${SED:-sed} '/^Libs:/ s|$| -lc++|' "$prefix_dir/lib/pkgconfig/libplacebo.pc" -i diff --git a/scripts/mpv/scripts/lua.sh b/scripts/mpv/scripts/lua.sh deleted file mode 100755 index cc3c52ec..00000000 --- a/scripts/mpv/scripts/lua.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - make clean - exit 0 -else - exit 255 -fi - -# Building seperately from source tree is not supported, this means we are forced to always clean -$0 clean - -mycflags=( - # ensures correct linking into libmpv.so - -fPIC - # bionic is missing decimal_point in localeconv [src/llex.c] - -Dgetlocaledecpoint\\\(\\\)=\\\(46\\\) - # force fallback as ftello/fseeko are not defined [src/liolib.c] - -Dlua_fseek -) - -# LUA_T= and LUAC_T= to disable building lua & luac -# -Dgetlocaledecpoint()=('.') fixes bionic missing decimal_point in localeconv -make CC="$CC" AR="$AR rc" RANLIB="$RANLIB" \ - MYCFLAGS="${mycflags[*]}" \ - PLAT=linux LUA_T= LUAC_T= -j$cores - -# TO_BIN=/dev/null disables installing lua & luac -make INSTALL=${INSTALL:-install} INSTALL_TOP="$prefix_dir" TO_BIN=/dev/null install - -# make pc only generates a partial pkg-config file because ???? -mkdir -p $prefix_dir/lib/pkgconfig -make pc >$prefix_dir/lib/pkgconfig/lua.pc -cat >>$prefix_dir/lib/pkgconfig/lua.pc <<'EOF' -Name: Lua -Description: -Version: ${version} -Libs: -L${libdir} -llua -Cflags: -I${includedir} -EOF diff --git a/scripts/mpv/scripts/mbedtls.sh b/scripts/mpv/scripts/mbedtls.sh deleted file mode 100755 index 32ae32f9..00000000 --- a/scripts/mpv/scripts/mbedtls.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - make clean - exit 0 -else - exit 255 -fi - -$0 clean # separate building not supported, always clean -if [[ "$ndk_triple" == "i686"* ]]; then - ./scripts/config.py unset MBEDTLS_AESNI_C -else - ./scripts/config.py set MBEDTLS_AESNI_C -fi - -make -j$cores no_test -make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/mpv-android.sh b/scripts/mpv/scripts/mpv-android.sh deleted file mode 100755 index af152810..00000000 --- a/scripts/mpv/scripts/mpv-android.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BUILD="$DIR/.." -MPV_ANDROID="$DIR/../.." - -. $BUILD/include/path.sh -. $BUILD/include/depinfo.sh - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $MPV_ANDROID/{app,.}/build $MPV_ANDROID/app/src/main/{libs,obj} - exit 0 -else - exit 255 -fi - -[ -n "$ANDROID_SIGNING_KEY" ] && BUNDLE=1 - -nativeprefix () { - if [ -f $BUILD/prefix/$1/lib/libmpv.so ]; then - echo $BUILD/prefix/$1 - else - echo >&2 "Warning: libmpv.so not found in native prefix for $1, support will be omitted" - fi -} - -prefix32=$(nativeprefix "armv7l") -prefix64=$(nativeprefix "arm64") -prefix_x64=$(nativeprefix "x86_64") -prefix_x86=$(nativeprefix "x86") - -if [[ -z "$prefix32" && -z "$prefix64" && -z "$prefix_x64" && -z "$prefix_x86" ]]; then - echo >&2 "Error: no mpv library detected." - exit 255 -fi - -PREFIX32=$prefix32 PREFIX64=$prefix64 PREFIX_X64=$prefix_x64 PREFIX_X86=$prefix_x86 \ -ndk-build -C app/src/main -j$cores - -targets=(assembleDebug) -if [ -z "$DONT_BUILD_RELEASE" ]; then - targets+=(assembleRelease) - [ -n "$BUNDLE" ] && targets+=(bundleRelease) -fi -./gradlew "${targets[@]}" - -if [ -n "$ANDROID_SIGNING_KEY" ]; then - cd "${MPV_ANDROID}/app/build/outputs/apk" - apksigner=${ANDROID_HOME}/build-tools/${v_sdk_build_tools}/apksigner - for v in default api29; do - pushd $v - # sign the universal debug APK - "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ - --in debug/app-$v-universal-debug.apk --out debug/app-$v-universal-debug-signed.apk - # but all of the release APKs - for apk in release/*-unsigned.apk; do - "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ - --in $apk --out ${apk/-unsigned/-signed} - done - popd - done - # and the bundle - cd ../bundle - if [ -n "$BUNDLE" ]; then - if [ -z "$ANDROID_SIGNING_ALIAS" ]; then - echo >&2 "Error: ANDROID_SIGNING_ALIAS must be set to use jarsigner" - exit 1 - fi - pushd defaultRelease - jarsigner -keystore "${ANDROID_SIGNING_KEY}" -signedjar \ - app-default-release-signed.aab app-default-release.aab \ - "${ANDROID_SIGNING_ALIAS}" - popd - fi -fi diff --git a/scripts/mpv/scripts/mpv.sh b/scripts/mpv/scripts/mpv.sh deleted file mode 100755 index feb85c95..00000000 --- a/scripts/mpv/scripts/mpv.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -unset CC CXX # meson wants these unset - -meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ - --default-library shared \ - -Diconv=disabled -Dlua=enabled \ - -Dlibmpv=true -Dcplayer=false \ - -Dmanpage-build=disabled - -ninja -C $build -j$cores -if [ -f $build/libmpv.a ]; then - echo >&2 "Meson fucked up, forcing rebuild." - $0 clean - exec $0 build -fi -DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/unibreak.sh b/scripts/mpv/scripts/unibreak.sh deleted file mode 100755 index d18dbea5..00000000 --- a/scripts/mpv/scripts/unibreak.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -e - -. ../../include/path.sh - -build=_build$ndk_suffix - -if [ "$1" == "build" ]; then - true -elif [ "$1" == "clean" ]; then - rm -rf $build - exit 0 -else - exit 255 -fi - -mkdir -p $build -cd $build - -../configure \ - --host=$ndk_triple --with-pic \ - --enable-static --disable-shared - -make -j$cores -make DESTDIR="$prefix_dir" install diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b95efe8..76b787aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,8 +16,19 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + @Suppress("ktlint:standard:property-naming") + val WholphinExtensionsUsername: String? by settings + if (!WholphinExtensionsUsername.isNullOrBlank()) { + maven("https://maven.pkg.github.com/damontecres/wholphin-extensions") { + name = "WholphinExtensions" + credentials(PasswordCredentials::class) + } + } } } 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<String>.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 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> 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<Test>().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..83266ff5 --- /dev/null +++ b/wolfskin-design/discover-tokens.json @@ -0,0 +1,279 @@ +{ + "discover": { + "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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <metadata id="metadata3396"> + <rdf:RDF> + <cc:Work rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> + <dc:title/> + </cc:Work> + </rdf:RDF> + </metadata> + <g id="layer1" fill="#f93208"> + <path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/> + <path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/> + </g> + <g id="layer2"> + <path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/> + </g> +</svg> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="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" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="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" id="i3Prh1JpXt"></path><path d="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" id="a4ov9rRGQm"></path><path d="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" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="145" height="140"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg> \ 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <metadata id="metadata3886"> + <rdf:RDF> + <cc:Work rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> + <dc:title/> + </cc:Work> + </rdf:RDF> + </metadata> + <g id="g3998" transform="matrix(.0042962 .96345 -.96345 .0042962 135.21 .16894)"></g> + <g id="layer4"> + <path id="path3462" d="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" fill="#07a23b"/> + <g fill="#fff"> + <path id="path3505" d="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"/> + <path id="path3551" d="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"/> + <path id="path3553" d="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"/> + <path id="path3555" d="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"/> + </g> + </g> + <g id="layer5"> + <path id="path4031" d="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" fill="#03621e"/> + </g> + <g id="layer6" fill="#fdd600"> + <path id="path4136" d="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"/> + <path id="path4174" d="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"/> + <path id="path4176" d="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"/> + <path id="path4178" d="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"/> + <path id="path4180" d="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"/> + <path id="path4182" d="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"/> + <path id="path4184" d="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"/> + <path id="path4186" d="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"/> + <path id="path4188" d="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"/> + <path id="path4190" d="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"/> + <path id="path4192" d="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"/> + <path id="path4194" d="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"/> + <path id="path4196" d="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"/> + <path id="path4198" d="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"/> + <path id="path4200" d="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"/> + </g> + <g id="layer7"> + <path id="path4203" d="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" fill="#09a339"/> + </g> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>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..23c5c596 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -0,0 +1,184 @@ +// 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) + Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", 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.stage.readiness.forEachIndexed { index, word -> + Row { + val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> 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 (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..f8aa27df --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -0,0 +1,128 @@ +// 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 +/** Household acquisition stages, ordered independently from their display wording. */ +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")), + 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")), + 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 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 ?: "Unknown" + } +} + +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 openCount: Int get() = items.count { it.stage != ComingInStage.Ready } +} + +/** 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..92e63ad9 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -0,0 +1,97 @@ +// 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) { + WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) + } + 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..396a5862 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt @@ -0,0 +1,82 @@ +// 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.values.map { DiscoverSeed(it.first, it.second.toList()) }, 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..cd66673f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -0,0 +1,47 @@ +// 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.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) + 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.forEachIndexed { index, seed -> + val items = take(seed.items, size) + if (items.size >= 3) result += DiscoverLane("pk-w$index", "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..2fe37462 --- /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 contribution group preserves feed order rather than assigning a visible rank. */ +data class DiscoverSeed(val title: String, val items: List) + +/** 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..bd6c40ef --- /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(L.rowHeadingBand + (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..92e8317e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.gestures.animateScrollBy +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.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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +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 = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt() + val first = state.shell.view.cards["first:${lane.id}"] ?: 0 + var initialized by remember { mutableStateOf(false) } + LaunchedEffect(column, count) { + var target = first + if (column > target + slots - 2) target = column - (slots - 2) + if (column < target + 1) target = (column - 1).coerceAtLeast(0) + target = target.coerceIn(0, (count - slots).coerceAtLeast(0)) + state.shell.view.cards["first:${lane.id}"] = target + val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset + if (initialized && offset != null) { + if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) + } else cards.scrollToItem(target) + initialized = true + } + Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + 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 = L.rowHeadingBand - 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), L.rowHeadingBand + 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..e91b22cd --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -0,0 +1,115 @@ +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") HomeAction.Play(title, row.heroKind == "resume") 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 { + var first = previous + if (column > first + focusSlots - 2) first = column - (focusSlots - 2) + if (column < first + 1) first = (column - 1).coerceAtLeast(0) + return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0)) +} 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..eb487f62 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -0,0 +1,92 @@ +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 primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else "" + PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) } + if (row.heroKind != "pick") 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..d2e6adb2 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -0,0 +1,92 @@ +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 hasSeeAll: Boolean get() = total > cards.size + val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 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, val resume: Boolean) : 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..84766c0e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -0,0 +1,136 @@ +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) { + if (view.home.positionedKey != null) { + view.home.focusedKey?.let { view.home.tracks[it] }?.let { track -> + track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) + } + view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset) + } + } + LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) { + 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) + view.home.positionedKey = key + view.home.positionedReset = view.home.resetScroll + 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))) + } + 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..9b97fe8b --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -0,0 +1,110 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +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.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.collectLatest +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 targetFirst by rememberUpdatedState(first) + val density = LocalDensity.current + LaunchedEffect(track) { + track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) + var positionedFirst = targetFirst + snapshotFlow { targetFirst }.collectLatest { target -> + if (target != positionedFirst) { + positionedFirst = target + val pitch = with(density) { (width + K.posterGap).toPx() } + track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base)) + } + } + } + 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..011a1d70 --- /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(horizontal = 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..0bc4d335 --- /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 household request count shares the sheet's snapshot and excludes Ready. */ +@Composable +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: 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") openRequests 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, openRequests: 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 = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" + 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/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..5eca15da --- /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, + settingsOpenRequests: 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, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) 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..1ea37eb4 --- /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.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, + 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)) + } + 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.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + val height = WolfskinTokens.Card.tileH - 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.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, 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..903bf508 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -0,0 +1,117 @@ +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 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(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..103e8818 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -0,0 +1,115 @@ +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, +) + +/** 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) : 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..85f847f5 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -0,0 +1,209 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot +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() } + 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) { + FloatArray(displayedRows.size + 1).apply { + displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).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) { + 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).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(L.row_dhead)) { TitleHeader(state, active) } + "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())).coerceAtLeast(D.minimumEndPadding))) + } + } + } +} + +private fun titleRowHeight(row: String): Dp = when (row) { + "header" -> L.row_dhead + "seasons" -> L.row_chips + "episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + 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 + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), 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..641fead0 --- /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.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight) + } + "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..15a74880 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -0,0 +1,169 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.gestures.animateScrollBy +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 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) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } + 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) WolfskinTokens.Card.tileH 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) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } + 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, WolfskinTokens.Card.tileH).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) + } + 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()) + } + } + } +} + +/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ +private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { + val first = firstVisibleItemIndex + val next = when { + column < first -> column + column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0) + else -> first + } + if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec) +} + +private val titleTrackScrollSpec = tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)) 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..242e0e6b --- /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, width, 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, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else 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..2e5da10c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -0,0 +1,291 @@ +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 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 || 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 + focus(next, (columns[next] ?: 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)) } + "seasons" -> { 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" -> 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 (target != null) { + 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..48d6c5e6 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -0,0 +1,118 @@ +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 total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) + val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - 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.ringAvatarRing) + .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..4e0822bf --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -0,0 +1,76 @@ +package org.justinvisser.wolfskin.design + +import android.content.Context +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 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(source).size(width, height).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)) + } + } +} 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..4a9ec01c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -0,0 +1,672 @@ +// 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 = 1280 + const val heroRequestHeight = 720 + 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 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 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..ed62d6f4 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt @@ -0,0 +1,33 @@ +// 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("Seed", titles.subList(3, 5))), + 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 }) + } +} 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..01c953b8 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -0,0 +1,224 @@ +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); assertTrue((action as HomeAction.Play).resume) } + 1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); 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 + assertFalse(play.resume) + 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/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..780a6575 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -0,0 +1,150 @@ +// 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() + rule.onNodeWithText("Requests · 5 open").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)) + assertEquals(5, snapshot.openCount) + assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank }) + } +} 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..3774962c --- /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, openRequests: 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), settingsOpenRequests = openRequests) + } + } + 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", openRequests = 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..5138e020 --- /dev/null +++ b/wolfskin-design/title-tokens.json @@ -0,0 +1,130 @@ +{ + "title": { + "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..9c7f17f1 --- /dev/null +++ b/wolfskin-design/tokens.css @@ -0,0 +1,493 @@ +/* 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-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-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..f8121dc4 --- /dev/null +++ b/wolfskin-design/tokens.json @@ -0,0 +1,1616 @@ +{ + "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", + "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" + }, + "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": [ + 1280, + 720 + ], + "decode": "ARGB_8888", + "bytes": 3686400, + "source": "index.html:549", + "usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" + }, + "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..b3fabec1 --- /dev/null +++ b/wolfskin-design/tokens.sha256 @@ -0,0 +1,8 @@ +1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json +85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json +be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json +c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json +b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css +3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json