diff --git a/.cargo/config.toml b/.cargo/config.toml index 3051c6dc..806c651d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,18 +1,17 @@ # TODO: static link glibc +[alias] +new-migration = "run --quiet -p xtask -- new-migration" + # [target.x86_64-unknown-linux-gnu] # linker = "x86_64-linux-gnu-gcc" # rustflags = ["-C", "target-feature=+crt-static"] -# [target.x86_64-unknown-linux-musl] -# rustflags = ["-L/usr/local/lib", "-L/usr/lib", "-L/lib"] - -[target.aarch64-unknown-linux-gnu] -linker = "aarch64-linux-gnu-gcc" -# rustflags = ["-C", "target-feature=+crt-static"] +[target.x86_64-unknown-linux-musl] +linker = "x86_64-alpine-linux-musl-gcc" [target.aarch64-unknown-linux-musl] -linker = "aarch64-linux-gnu-gcc" +linker = "aarch64-alpine-linux-musl-gcc" [target.armv7-unknown-linux-gnueabihf] linker = "arm-linux-gnueabihf-gcc" diff --git a/.github/workflows/ci-clippy.yml b/.github/workflows/ci-clippy.yml new file mode 100644 index 00000000..2a7c0ecf --- /dev/null +++ b/.github/workflows/ci-clippy.yml @@ -0,0 +1,37 @@ +--- +name: CI-Clippy +permissions: {} + +on: + workflow_call: + +jobs: + clippy: + name: Clippy + permissions: + contents: read + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libayatana-appindicator3-dev \ + libglib2.0-dev \ + libgtk-3-dev \ + libxdo-dev + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + components: clippy + cache: true + cache-on-failure: false + + - name: Clippy + run: cargo clippy --locked -- -D warnings diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml new file mode 100644 index 00000000..2c5bb337 --- /dev/null +++ b/.github/workflows/ci-coverage.yml @@ -0,0 +1,46 @@ +--- +name: CI-Coverage +permissions: {} + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + +jobs: + coverage: + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-pc-windows-msvc + name: Coverage (${{ matrix.target }}) + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download coverage artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-${{ matrix.target }} + path: _coverage + + - name: Upload coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_coverage/cobertura.xml + flags: ${{ matrix.target }} + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.github/workflows/ci-flatpak.yml b/.github/workflows/ci-flatpak.yml new file mode 100644 index 00000000..8a8ed051 --- /dev/null +++ b/.github/workflows/ci-flatpak.yml @@ -0,0 +1,186 @@ +--- +name: CI-Flatpak +permissions: {} + +on: + workflow_call: + inputs: + release_commit: + required: true + type: string + release_version: + required: true + type: string + +env: + APP_ID: dev.lizardbyte.app.Koko + FREEDESKTOP_SDK_VERSION: "25.08" + NODE_VERSION: "24" + PYTHON_VERSION: "3.14" + +jobs: + flatpak: + name: ${{ matrix.arch }} + env: + MATRIX_ARCH: ${{ matrix.arch }} + permissions: + contents: read + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Sync Python tools + run: | + uv sync --locked --only-group flatpak \ + --python "${PYTHON_VERSION}" \ + --no-python-downloads \ + --no-install-project + + - name: Setup Flatpak dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y flatpak + + sudo su "$(whoami)" -c "flatpak --user remote-add --if-not-exists flathub \ + https://flathub.org/repo/flathub.flatpakrepo + " + + sudo su "$(whoami)" -c "flatpak --user install -y flathub \ + org.flatpak.Builder \ + org.freedesktop.Platform/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk.Extension.node${NODE_VERSION}/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + org.freedesktop.Sdk.Extension.rust-stable/${MATRIX_ARCH}/${FREEDESKTOP_SDK_VERSION} \ + " + + flatpak run org.flatpak.Builder --version + + - name: Generate Flatpak Node sources + run: | + uv run --locked --no-sync python -m flatpak_node_generator \ + npm crates/client-web/package-lock.json \ + --node-sdk-extension "org.freedesktop.Sdk.Extension.node${NODE_VERSION}//${FREEDESKTOP_SDK_VERSION}" \ + --output generated-node-sources.json + + - name: Generate Flatpak Cargo sources + run: | + uv run --locked --no-sync python \ + ./packaging/linux/flatpak/deps/flatpak-builder-tools/cargo/flatpak-cargo-generator.py \ + Cargo.lock \ + --output generated-cargo-sources.json + + - name: Configure Flatpak manifest + env: + INPUT_RELEASE_COMMIT: ${{ inputs.release_commit }} + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + REPOSITORY_CLONE_URL: ${{ github.event.repository.clone_url }} + run: | + set -euo pipefail + build_date="$(git show -s --format=%cs "${INPUT_RELEASE_COMMIT}")" + + mkdir -p build artifacts + cp generated-node-sources.json build/ + cp generated-cargo-sources.json build/ + cp -r packaging/linux/flatpak/deps/shared-modules build/shared-modules + cp -r packaging/linux/flatpak/modules build/modules + cp "packaging/linux/flatpak/${APP_ID}.yml" "build/${APP_ID}.yml" + cp "packaging/linux/flatpak/${APP_ID}.metainfo.xml" "build/${APP_ID}.metainfo.xml" + + sed -i \ + -e "s|@BUILD_DATE@|${build_date}|g" \ + -e "s|@BUILD_VERSION@|${INPUT_RELEASE_VERSION}|g" \ + -e "s|@GITHUB_CLONE_URL@|${REPOSITORY_CLONE_URL}|g" \ + -e "s|@GITHUB_COMMIT@|${INPUT_RELEASE_COMMIT}|g" \ + "build/${APP_ID}.yml" + + sed -i \ + -e "s|@BUILD_DATE@|${build_date}|g" \ + -e "s|@BUILD_VERSION@|${INPUT_RELEASE_VERSION}|g" \ + "build/${APP_ID}.metainfo.xml" + + - name: Debug manifest + working-directory: build + run: cat "${APP_ID}.yml" + + - name: Cache Flatpak build + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ./build/.flatpak-builder + key: flatpak-${{ matrix.arch }}-${{ github.sha }} + restore-keys: | + flatpak-${{ matrix.arch }}- + + - name: Build Linux Flatpak + working-directory: build + run: | + sudo su "$(whoami)" -c "flatpak run org.flatpak.Builder \ + --arch=${MATRIX_ARCH} \ + --force-clean \ + --repo=repo \ + --sandbox \ + build-koko ${APP_ID}.yml" + + sudo su "$(whoami)" -c "flatpak build-bundle \ + --arch=${MATRIX_ARCH} \ + ./repo \ + ../artifacts/koko_${MATRIX_ARCH}.flatpak ${APP_ID}" + + - name: Lint Flatpak + working-directory: build + run: | + exceptions_file="${GITHUB_WORKSPACE}/packaging/linux/flatpak/exceptions.json" + + flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ + --exceptions \ + --user-exceptions "${exceptions_file}" \ + manifest \ + "${APP_ID}.yml" + + flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ + --exceptions \ + --user-exceptions "${exceptions_file}" \ + repo \ + repo + + - name: Package Flathub repo archive + if: matrix.arch == 'x86_64' + run: | + mkdir -p flathub/modules + cp "./build/generated-cargo-sources.json" "./flathub/" + cp "./build/generated-node-sources.json" "./flathub/" + cp "./build/${APP_ID}.yml" "./flathub/" + cp "./packaging/linux/flatpak/${APP_ID}.desktop" "./flathub/" + cp "./build/${APP_ID}.metainfo.xml" "./flathub/" + cp "./packaging/linux/flatpak/README.md" "./flathub/" + cp "./packaging/linux/flatpak/flathub.json" "./flathub/" + cp -r "./packaging/linux/flatpak/modules/." "./flathub/modules/" + # submodules will need to be handled in the workflow that creates the PR + tar -czf ./artifacts/flathub.tar.gz -C ./flathub . + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-flatpak-${{ matrix.arch }} + path: artifacts/ diff --git a/.github/workflows/ci-macos-dmg.yml b/.github/workflows/ci-macos-dmg.yml new file mode 100644 index 00000000..2c02532c --- /dev/null +++ b/.github/workflows/ci-macos-dmg.yml @@ -0,0 +1,212 @@ +--- +name: CI-macOS-DMG +permissions: {} + +on: + workflow_call: + inputs: + publish_release: + required: true + type: string + release_version: + required: true + type: string + secrets: + # email address + APPLE_ID: + required: false + # 10-character Team ID + APPLE_TEAM_ID: + required: false + # app-specific password in APPLE_ID's account that must be named "notarytool" + # https://support.apple.com/en-us/102654 + APPLE_NOTARYTOOL_PASSWORD: + required: false + # Developer ID Application: Full Name (TEAMIDHERE) + APPLE_CODESIGN_IDENTITY: + required: false + # pkcs12 export from Xcode in base64 + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: + required: false + # pkcs12 password added by Xcode export + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: + required: false + +env: + CARGO_TERM_COLOR: always + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + MACOSX_DEPLOYMENT_TARGET: "14.2" + +jobs: + build_dmg: + name: ${{ matrix.name }} + permissions: + contents: read + runs-on: ${{ matrix.os }} + outputs: + notarytool_submission_id_arm64: ${{ steps.notarize_submit.outputs.submission_id_arm64 }} + notarytool_submission_id_x86_64: ${{ steps.notarize_submit.outputs.submission_id_x86_64 }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + name: macOS-arm64 + arch: arm64 + target: aarch64-apple-darwin + - os: macos-15-intel + name: macOS-x86_64 + arch: x86_64 + target: x86_64-apple-darwin + steps: + - name: Install Apple certificate + if: inputs.publish_release == 'true' + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 + with: + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download macOS build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-macos-${{ matrix.target }} + path: . + + - name: Package DMG + env: + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + SHOULD_SIGN: ${{ inputs.publish_release }} + run: | + sign_args=() + if [[ "${SHOULD_SIGN}" == "true" ]]; then + sign_args+=(--sign) + fi + + bash packaging/macos/package-dmg.sh \ + --target "${{ matrix.target }}" \ + --version "${INPUT_RELEASE_VERSION:-0.0.0}" \ + --binary "target/${{ matrix.target }}/release/koko" \ + --output-dir artifacts \ + "${sign_args[@]}" + + - name: Submit for notarization + id: notarize_submit + if: inputs.publish_release == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + MATRIX_ARCH: ${{ matrix.arch }} + MATRIX_TARGET: ${{ matrix.target }} + run: | + if [[ -n "${APPLE_NOTARYTOOL_PASSWORD}" ]]; then + submission_id=$(xcrun notarytool submit "artifacts/koko-${MATRIX_TARGET}.dmg" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" \ + --output-format json \ + | jq -r '.id') + echo "Submission ID: ${submission_id}" + echo "submission_id_${MATRIX_ARCH}=${submission_id}" >> "${GITHUB_OUTPUT}" + fi + + - name: Set artifact prefix + id: artifact_prefix + env: + INPUTS_PUBLISH_RELEASE: ${{ inputs.publish_release }} + run: | + if [[ "${INPUTS_PUBLISH_RELEASE}" == "true" ]]; then + echo "prefix=unsigned" >> "${GITHUB_OUTPUT}" + else + echo "prefix=koko" >> "${GITHUB_OUTPUT}" + fi + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: ${{ steps.artifact_prefix.outputs.prefix }}-${{ matrix.name }}-dmg + path: artifacts/ + + notarize_dmg: + name: Notarize ${{ matrix.name }} + needs: build_dmg + if: inputs.publish_release == 'true' + permissions: + contents: read + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + name: macOS-arm64 + arch: arm64 + target: aarch64-apple-darwin + - os: macos-15-intel + name: macOS-x86_64 + arch: x86_64 + target: x86_64-apple-darwin + steps: + - name: Install Apple certificate + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 + with: + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + - name: Download DMG artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsigned-${{ matrix.name }}-dmg + path: artifacts + + - name: Wait for notarization and staple + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + MATRIX_TARGET: ${{ matrix.target }} + SUBMISSION_ID: ${{ matrix.arch == 'arm64' + && needs.build_dmg.outputs.notarytool_submission_id_arm64 + || needs.build_dmg.outputs.notarytool_submission_id_x86_64 }} + run: | + if [[ -z "${SUBMISSION_ID}" ]]; then + echo "No submission ID found; skipping notarization wait." + exit 0 + fi + + echo "Polling notarization status for submission: ${SUBMISSION_ID}" + while true; do + status=$(xcrun notarytool info "${SUBMISSION_ID}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" \ + --output-format json \ + | jq -r '.status') + echo "Current status: ${status}" + if [[ "${status}" == "Accepted" ]]; then + echo "Notarization accepted." + break + elif [[ "${status}" == "Invalid" || "${status}" == "Rejected" ]]; then + echo "Notarization failed with status: ${status}" + xcrun notarytool log "${SUBMISSION_ID}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" + exit 1 + fi + echo "Status is '${status}', waiting 30 seconds before retrying..." + sleep 30 + done + + xcrun stapler staple -v "artifacts/koko-${MATRIX_TARGET}.dmg" + + - name: Upload stapled artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-${{ matrix.name }}-dmg + path: artifacts/ diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml new file mode 100644 index 00000000..b4d6f5eb --- /dev/null +++ b/.github/workflows/ci-release.yml @@ -0,0 +1,48 @@ +--- +name: CI-Release +permissions: {} + +on: + workflow_call: + inputs: + release_body: + required: true + type: string + release_generate_release_notes: + required: true + type: string + release_tag: + required: true + type: string + secrets: + GH_BOT_TOKEN: + required: true + +jobs: + release: + name: Release + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Download build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: artifacts + pattern: koko-* + merge-multiple: true + + - name: Debug artifacts + run: ls -l artifacts + + - name: Create/Update GitHub Release + uses: LizardByte/actions/actions/release_create@200eaeb897a2b065a65cb6f16b41077432007490 # v2026.605.34721 + with: + allowUpdates: true + body: ${{ inputs.release_body }} + draft: true + generateReleaseNotes: ${{ inputs.release_generate_release_notes }} + name: ${{ inputs.release_tag }} + prerelease: true + tag: ${{ inputs.release_tag }} + token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml new file mode 100644 index 00000000..9f42049b --- /dev/null +++ b/.github/workflows/ci-rust.yml @@ -0,0 +1,265 @@ +--- +name: CI-Rust +permissions: {} + +on: + workflow_call: + inputs: + release_version: + default: '' + required: false + type: string + +jobs: + rust: + strategy: + fail-fast: false + matrix: + run: + - test + - build + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + include: + - target: x86_64-unknown-linux-musl # Alpine + os: ubuntu-latest + container: rust:1.96-alpine3.24 + cargo_features: '--no-default-features' + shell: bash + build_artifact_prefix: build-alpine + build_artifact_path: artifacts + - target: aarch64-unknown-linux-musl # Alpine + os: ubuntu-24.04-arm + container: rust:1.96-alpine3.24 + cargo_features: '--no-default-features' + shell: bash + build_artifact_prefix: build-alpine + build_artifact_path: artifacts + - target: x86_64-unknown-linux-gnu # Ubuntu + os: ubuntu-latest + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-ubuntu + build_artifact_path: artifacts + - target: aarch64-unknown-linux-gnu # Ubuntu + os: ubuntu-24.04-arm + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-ubuntu + build_artifact_path: artifacts + - target: x86_64-apple-darwin # macOS/Intel + os: macos-latest + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-macos + build_artifact_path: build-artifact + - target: aarch64-apple-darwin # macOS/Apple Silicon + os: macos-latest + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-macos + build_artifact_path: build-artifact + - target: x86_64-pc-windows-msvc # Windows + os: windows-latest + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-windows + build_artifact_path: build-artifact + - target: aarch64-pc-windows-msvc # Windows/ARM64 + os: windows-11-arm + container: '' + cargo_features: '' + shell: bash + build_artifact_prefix: build-windows + build_artifact_path: build-artifact + exclude: + - run: test + target: aarch64-pc-windows-msvc + name: ${{ matrix.run == 'test' && 'Test' || 'Build' }} (${{ matrix.target }}) + permissions: + contents: read + runs-on: ${{ matrix.os }} + container: + image: ${{ matrix.container }} + defaults: + run: + shell: ${{ matrix.shell }} + env: + CARGO_TERM_COLOR: always + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + steps: + - name: Fix arm64 Alpine container + if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') + uses: laverdet/alpine-arm64@7f0f72ee2f71eb2324e5888e8b6e42b1b53e6160 # v1.0.0 + + - name: Prepare Alpine container + if: contains(matrix.container, 'alpine') + shell: sh + run: | + set -eux + apk add --no-cache \ + bash \ + build-base \ + git \ + nodejs \ + npm \ + openssl-dev \ + openssl-libs-static \ + p7zip \ + pkgconf \ + zlib-dev \ + zlib-static + + echo "/usr/local/cargo/bin" >> "${GITHUB_PATH}" + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install Ubuntu dependencies + if: matrix.container == '' && contains(matrix.os, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y \ + libayatana-appindicator3-dev \ + libdbus-1-dev \ + libgtk-3-dev \ + libssl-dev \ + libxdo-dev \ + p7zip-full \ + pkg-config + + - name: Setup Rust + if: matrix.container == '' + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + target: ${{ matrix.target }} + cache: true + cache-on-failure: false + + - name: Setup Node + if: matrix.container == '' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: crates/client-web/package-lock.json + + - name: Install Cargo tools + if: matrix.run == 'test' || env.INPUT_RELEASE_VERSION != '' + run: | + cargo_run_bin_version="$( + cargo metadata --locked --no-deps --format-version 1 \ + | sed -n 's/.*"cargo-run-bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + )" + if [[ -z "${cargo_run_bin_version}" ]]; then + echo "Failed to parse cargo-run-bin version from cargo metadata" >&2 + exit 1 + fi + cargo install --locked cargo-run-bin --version "${cargo_run_bin_version}" + if [[ -n "${INPUT_RELEASE_VERSION}" ]]; then + cargo bin cargo-set-version --version + fi + if [[ "${{ matrix.run }}" == "test" ]]; then + cargo bin cargo-tarpaulin --version + fi + + - name: Update Version + if: matrix.run == 'build' && env.INPUT_RELEASE_VERSION != '' + run: | + cargo bin cargo-set-version "${INPUT_RELEASE_VERSION}" + cargo update --workspace + cargo metadata --locked --no-deps --format-version 1 > /dev/null + + - name: Build web client + working-directory: crates/client-web + run: | + npm ci --ignore-scripts + npm run build + + - name: Test + id: test + if: matrix.run == 'test' + run: | + tarpaulin_args=() + case "${{ matrix.target }}" in + aarch64-unknown-linux-gnu) + tarpaulin_args+=(--jobs 1) + ;; + esac + + cargo bin cargo-tarpaulin \ + --locked \ + --color always \ + --engine llvm \ + "${tarpaulin_args[@]}" \ + --no-fail-fast \ + --out Xml \ + ${{ matrix.cargo_features }} \ + --target ${{ matrix.target }} \ + --verbose + + - name: Upload coverage artifact + if: >- + always() && + matrix.run == 'test' && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: coverage-${{ matrix.target }} + path: cobertura.xml + + - name: Build + if: matrix.run == 'build' + run: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release + + - name: Create 7z archive + if: matrix.run == 'build' && matrix.build_artifact_path == 'artifacts' + run: | + mkdir -p artifacts + + extension="" + if [[ "${{ matrix.target }}" = *"windows"* ]]; then + extension=".exe" + fi + + 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ + "./assets" \ + "./target/${{ matrix.target }}/release/koko${extension}" + + - name: Prepare build artifact + if: matrix.run == 'build' && matrix.build_artifact_path == 'build-artifact' + run: | + mkdir -p \ + "build-artifact/target/${{ matrix.target }}/release" \ + "build-artifact/crates/client-web" + + extension="" + if [[ "${{ matrix.target }}" = *"windows"* ]]; then + extension=".exe" + fi + + cp \ + "target/${{ matrix.target }}/release/koko${extension}" \ + "build-artifact/target/${{ matrix.target }}/release/koko${extension}" + cp -R "crates/client-web/dist" "build-artifact/crates/client-web/dist" + + - name: Upload build artifact + if: matrix.run == 'build' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: ${{ matrix.build_artifact_prefix }}-${{ matrix.target }} + path: ${{ matrix.build_artifact_path }} diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml new file mode 100644 index 00000000..392feb65 --- /dev/null +++ b/.github/workflows/ci-windows-installer.yml @@ -0,0 +1,163 @@ +--- +name: CI-Windows-Installer +permissions: {} + +on: + workflow_call: + inputs: + azure_signing_account: + default: '' + required: false + type: string + azure_signing_cert_profile: + default: '' + required: false + type: string + azure_signing_endpoint: + default: '' + required: false + type: string + publish_release: + default: false + required: false + type: boolean + release_version: + default: '' + required: false + type: string + secrets: + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TENANT_ID: + required: false + +jobs: + windows_installer: + name: Windows Installer (${{ matrix.target }}) + permissions: + contents: read + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + - target: aarch64-pc-windows-msvc + env: + CARGO_TERM_COLOR: always + INPUT_AZURE_SIGNING_ACCOUNT: ${{ inputs.azure_signing_account }} + INPUT_AZURE_SIGNING_CERT_PROFILE: ${{ inputs.azure_signing_cert_profile }} + INPUT_AZURE_SIGNING_ENDPOINT: ${{ inputs.azure_signing_endpoint }} + INPUT_PUBLISH_RELEASE: ${{ inputs.publish_release }} + INPUT_RELEASE_VERSION: ${{ inputs.release_version }} + WIX_EXTENSION_VERSION: 7.0.0 + WIX_TOOL_PATH: ${{ github.workspace }}\.wix + WIX_VERSION: 7.0.0 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download Windows build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-windows-${{ matrix.target }} + path: . + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + target: ${{ matrix.target }} + cache: true + cache-on-failure: false + + - name: Install Cargo tools + shell: pwsh + run: | + $metadata = cargo metadata --locked --no-deps --format-version 1 | ConvertFrom-Json + $cargoRunBinVersion = $metadata.metadata.ci.'cargo-run-bin' + cargo install --locked cargo-run-bin --version $cargoRunBinVersion + if ($env:INPUT_RELEASE_VERSION) { + cargo bin cargo-set-version --version + } + cargo bin cargo-wix --version + + - name: Update Version + if: env.INPUT_RELEASE_VERSION != '' + shell: pwsh + run: | + cargo bin cargo-set-version $env:INPUT_RELEASE_VERSION + cargo update --workspace + cargo metadata --locked --no-deps --format-version 1 > $null + + - name: Setup dotnet + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '10.x' + + - name: Install WiX + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path $env:WIX_TOOL_PATH | Out-Null + dotnet tool install --tool-path $env:WIX_TOOL_PATH wix --version $env:WIX_VERSION + $env:WIX_TOOL_PATH >> $env:GITHUB_PATH + $env:PATH = "$env:WIX_TOOL_PATH;$env:PATH" + wix eula accept wix7 + wix extension add "WixToolset.UI.wixext/$env:WIX_EXTENSION_VERSION" + wix extension add "WixToolset.Util.wixext/$env:WIX_EXTENSION_VERSION" + wix --version + + - name: Sign Windows executable + if: env.INPUT_PUBLISH_RELEASE == 'true' && env.INPUT_AZURE_SIGNING_ACCOUNT != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ env.INPUT_AZURE_SIGNING_CERT_PROFILE }} + endpoint: ${{ env.INPUT_AZURE_SIGNING_ENDPOINT }} + files: ${{ github.workspace }}\target\${{ matrix.target }}\release\koko.exe + signing-account-name: ${{ env.INPUT_AZURE_SIGNING_ACCOUNT }} + + - name: Create Windows archive + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + 7z a "artifacts\koko-${{ matrix.target }}.7z" ` + ".\assets" ` + ".\target\${{ matrix.target }}\release\koko.exe" + + - name: Package Windows installer + shell: pwsh + run: | + cargo bin cargo-wix ` + --toolset modern ` + --target ${{ matrix.target }} ` + --no-build ` + --target-bin-dir "${{ github.workspace }}\target\${{ matrix.target }}\release" ` + --package koko ` + --output "artifacts\koko-${{ matrix.target }}-installer.msi" ` + --nocapture ` + crates/server/Cargo.toml + + - name: Sign Windows installer + if: env.INPUT_PUBLISH_RELEASE == 'true' && env.INPUT_AZURE_SIGNING_ACCOUNT != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ env.INPUT_AZURE_SIGNING_CERT_PROFILE }} + endpoint: ${{ env.INPUT_AZURE_SIGNING_ENDPOINT }} + files-folder: artifacts + files-folder-filter: msi + files-folder-recurse: false + signing-account-name: ${{ env.INPUT_AZURE_SIGNING_ACCOUNT }} + + - name: Upload Artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: error + name: koko-${{ matrix.target }} + path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8318b14..c004936c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,342 +36,133 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} - build: - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu # Debian - os: ubuntu-latest - container: '' - shell: bash - cargo_env: '' - # TODO: Fix compiling for musl - # - target: x86_64-unknown-linux-musl # Alpine - # os: ubuntu-latest - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - - target: aarch64-unknown-linux-gnu # Debian - os: ubuntu-24.04-arm - container: '' - shell: bash - cargo_env: '' - # TODO: Fix cross compiling for the below targets - # - target: aarch64-unknown-linux-musl # Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: armv7-unknown-linux-gnueabihf # Raspberry Pi 2-5/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: armv7-unknown-linux-musleabihf # Raspberry Pi 2-5/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - # - target: arm-unknown-linux-gnueabihf # Raspberry Pi 0-1/Debian - # os: ubuntu-24.04-arm - # shell: bash - # - target: arm-unknown-linux-musleabihf # Raspberry Pi 0-1/Alpine - # os: ubuntu-24.04-arm - # container: alpine:latest - # shell: sh - # cargo_env: "source $HOME/.cargo/env" - - target: x86_64-apple-darwin # macOS/Intel - os: macos-latest - container: '' - shell: bash - cargo_env: '' - - target: aarch64-apple-darwin # macOS/Apple Silicon - os: macos-latest - container: '' - shell: bash - cargo_env: '' - - target: x86_64-pc-windows-msvc # Windows - os: windows-latest - container: '' - shell: bash - cargo_env: '' - name: Build (${{ matrix.target }}) - needs: setup_release + dependency_policy: + name: Dependency Policy permissions: contents: read - runs-on: ${{ matrix.os }} - container: - image: ${{ matrix.container }} - defaults: - run: - shell: ${{ matrix.shell }} - env: - CARGO_TERM_COLOR: always + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Setup cross compiling (Debian) - id: cross_compile - if: contains(matrix.os, 'ubuntu') && matrix.container == null - run: | - echo "::group::distro detection" - # detect dist name like bionic, focal, etc - dist_name=$(lsb_release -cs) - ubuntu_version=$(lsb_release -rs) - ubuntu_major_version=${ubuntu_version%%.*} - echo "detected dist name: $dist_name" - echo "detected ubuntu version: $ubuntu_version" - echo "detected ubuntu major version: $ubuntu_major_version" - echo "::endgroup::" - - echo "::group::install aptitude" - sudo apt-get update # must run before changing sources file - sudo apt-get install -y \ - aptitude - echo "::endgroup::" - - echo "::group::dependencies prep" - dependencies=() - - # extra dependencies for cross-compiling - cross_compile=false - package_arch=$(dpkg --print-architecture) - pkg_config_sysroot_dir="/usr/lib/${package_arch}" - qemu_command="" - if [[ ${{ matrix.target }} == *"aarch64"* && $package_arch != "arm64" ]]; then - dependencies+=("crossbuild-essential-arm64") - cross_compile=true - package_arch="arm64" - pkg_config_sysroot_dir="/usr/lib/aarch64-linux-gnu" - qemu_command="qemu-aarch64-static" - elif [[ ${{ matrix.target }} == *"arm"* && $package_arch != "armhf" ]]; then - dependencies+=("crossbuild-essential-armhf") - cross_compile=true - package_arch="armhf" - pkg_config_sysroot_dir="/usr/lib/arm-linux-gnueabihf" - qemu_command="qemu-arm-static" - fi - - if [[ $cross_compile == true ]]; then - dependencies+=( - "qemu-user" - "qemu-user-static" - ) - fi - - if [[ ${{ matrix.target }} == *"musl"* ]]; then - dependencies+=("musl-tools") - fi - - echo "cross compiling: $cross_compile" - echo "package architecture: $package_arch" - - dependencies+=( - "libayatana-appindicator3-dev:${package_arch}" # tray icon - "libglib2.0-dev:${package_arch}" - "libgtk-3-dev:${package_arch}" - "libxdo-dev:${package_arch}" - ) - echo "::endgroup::" - - echo "::group::apt sources" - extra_sources=$(cat <<- VAREOF - Types: deb - URIs: mirror+file:/etc/apt/apt-mirrors.txt - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: $(dpkg --print-architecture) - - Types: deb - URIs: https://ports.ubuntu.com/ubuntu-ports - Suites: ${dist_name} ${dist_name}-updates ${dist_name}-backports ${dist_name}-security - Components: main universe restricted multiverse - Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg - Architectures: ${package_arch} - VAREOF - ) - - # source file changed in 24.04 - if [[ $ubuntu_major_version -ge 24 ]]; then - source_file="/etc/apt/sources.list.d/ubuntu.sources" - else - source_file="/etc/apt/sources.list" - fi - - if [[ ${cross_compile} == true ]]; then - # print original sources - echo "original sources:" - sudo cat ${source_file} - echo "----" - - sudo dpkg --add-architecture ${package_arch} - - echo "$extra_sources" | sudo tee ${source_file} > /dev/null - echo "----" - echo "new sources:" - sudo cat ${source_file} - echo "----" - fi - echo "::endgroup::" - - echo "::group::output" - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - echo "QEMU_COMMAND=${qemu_command}" - - { - echo "CROSS_COMPILE=${cross_compile}" - echo "DEPENDENCIES=${dependencies[@]}" - echo "QEMU_COMMAND=${qemu_command}" - } >> "${GITHUB_OUTPUT}" - - { - echo "PKG_CONFIG_SYSROOT_DIR=${pkg_config_sysroot_dir}" - echo "PKG_CONFIG_PATH=${pkg_config_sysroot_dir}/pkgconfig" - } >> "${GITHUB_ENV}" - echo "::endgroup::" - - - name: Install system dependencies (Debian) - if: contains(matrix.os, 'ubuntu') && matrix.container == null - run: | - echo "::group::apt update" - sudo apt-get update - echo "::endgroup::" - - echo "::group::install dependencies" - sudo aptitude install -y --without-recommends ${{ steps.cross_compile.outputs.DEPENDENCIES }} - echo "::endgroup::" - - - name: Install system dependencies (Alpine) - if: contains(matrix.os, 'ubuntu') && contains(matrix.container, 'alpine') - run: | - echo "::group::apk update" - apk update - echo "::endgroup::" - - echo "::group::install dependencies" - apk add --no-cache \ - build-base \ - cargo \ - gcc \ - g++ \ - glib-dev \ - gtk+3.0-dev \ - libayatana-appindicator-dev \ - musl-dev \ - openssl-dev \ - xdotool-dev \ - pango-dev \ - harfbuzz-dev \ - cairo-dev \ - gdk-pixbuf-dev \ - wayland-dev \ - zlib-dev \ - gettext-dev - echo "::endgroup::" - - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: - target: ${{ matrix.target }} - components: 'clippy' cache: true cache-on-failure: false - # TODO: it may be possible to use cargo-bin in the future to install cargo dependencies, - # but right now it doesn't work without a lock file - # https://github.com/dustinblackman/cargo-run-bin/issues/27 - # cargo install cargo-run-bin - # cargo-bin --install - - name: Install cargo packages + - name: Install Cargo tools run: | - cargo install \ - cargo-edit \ - cargo-tarpaulin - - - name: Update Version - if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - run: cargo set-version ${{ needs.setup_release.outputs.release_version }} - - - name: Test - id: test - run: | - ${{ matrix.cargo_env }} - cargo tarpaulin \ - --color always \ - --engine llvm \ - --no-fail-fast \ - --out Xml \ - --target ${{ matrix.target }} \ - --verbose - - - name: Upload coverage - # any except canceled or skipped - if: >- - always() && - (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && - startsWith(github.repository, 'LizardByte/') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - disable_search: true - fail_ci_if_error: true - files: cobertura.xml - flags: ${{ matrix.target }} - token: ${{ secrets.CODECOV_TOKEN }} - verbose: true - - - name: Clippy - run: | - ${{ matrix.cargo_env }} - cargo clippy -- -D warnings - - - name: Build - run: | - ${{ matrix.cargo_env }} - cargo build --target ${{ matrix.target }} --release - - - name: Strip all debug symbols - # TODO: is this necessary - if: contains(matrix.os, 'ubuntu') - run: strip --strip-all target/${{ matrix.target }}/release/koko - - - name: Enable reading of cache - # TODO: is this necessary - continue-on-error: true - run: chmod -R a+rwX $HOME/.cargo target - - - name: Create 7z archive - run: | - mkdir -p artifacts - - extension="" - if [[ "${{ matrix.target }}" == *"windows"* ]]; then - extension=".exe" + cargo_run_bin_version="$( + cargo metadata --locked --no-deps --format-version 1 \ + | sed -n 's/.*"cargo-run-bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' + )" + if [[ -z "${cargo_run_bin_version}" ]]; then + echo "Failed to parse cargo-run-bin version from cargo metadata" >&2 + exit 1 fi + cargo install --locked cargo-run-bin --version "${cargo_run_bin_version}" + cargo bin cargo-deny --version - 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ - "./assets" \ - "./target/${{ matrix.target }}/release/koko${extension}" + - name: Check licenses + run: cargo bin cargo-deny check licenses - - name: Upload Artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - if-no-files-found: 'error' - name: koko-${{ matrix.target }} - path: artifacts + clippy: + name: Clippy + permissions: + contents: read + uses: ./.github/workflows/ci-clippy.yml - - name: Create/Update GitHub Release - if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - uses: LizardByte/actions/actions/release_create@200eaeb897a2b065a65cb6f16b41077432007490 # v2026.605.34721 - with: - allowUpdates: true - body: ${{ needs.setup_release.outputs.release_body }} - draft: true - generateReleaseNotes: ${{ needs.setup_release.outputs.release_generate_release_notes }} - name: ${{ needs.setup_release.outputs.release_tag }} - prerelease: true - tag: ${{ needs.setup_release.outputs.release_tag }} - token: ${{ secrets.GH_BOT_TOKEN }} + rust: + name: Rust + needs: setup_release + permissions: + contents: read + uses: ./.github/workflows/ci-rust.yml + with: + release_version: ${{ needs.setup_release.outputs.release_version }} + + build_windows_installer: + name: Build Windows Installer + needs: + - setup_release + - rust + permissions: + contents: read + uses: ./.github/workflows/ci-windows-installer.yml + with: + azure_signing_account: ${{ vars.AZURE_SIGNING_ACCOUNT }} + azure_signing_cert_profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + azure_signing_endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + publish_release: ${{ needs.setup_release.outputs.publish_release == 'true' }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + + build_macos_dmg: + name: Build macOS DMG + needs: + - setup_release + - rust + permissions: + contents: read + uses: ./.github/workflows/ci-macos-dmg.yml + with: + publish_release: ${{ needs.setup_release.outputs.publish_release }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + build_flatpak: + name: Build Flatpak + needs: setup_release + permissions: + contents: read + uses: ./.github/workflows/ci-flatpak.yml + with: + release_commit: ${{ needs.setup_release.outputs.release_commit }} + release_version: ${{ needs.setup_release.outputs.release_version }} + + coverage: + name: Coverage + if: >- + always() && + !cancelled() && + startsWith(github.repository, 'LizardByte/') + needs: rust + permissions: + contents: read + uses: ./.github/workflows/ci-coverage.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + release: + name: Release + if: >- + needs.setup_release.outputs.publish_release == 'true' && + startsWith(github.repository, 'LizardByte/') + needs: + - setup_release + - clippy + - rust + - build_windows_installer + - build_macos_dmg + - build_flatpak + permissions: + contents: read + uses: ./.github/workflows/ci-release.yml + with: + release_body: ${{ needs.setup_release.outputs.release_body }} + release_generate_release_notes: ${{ needs.setup_release.outputs.release_generate_release_notes }} + release_tag: ${{ needs.setup_release.outputs.release_tag }} + secrets: + GH_BOT_TOKEN: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 71ba4b12..21907de2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ # Generated by Cargo # will have compiled files and executables -debug/ -target/ - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +debug +target # These are backup files generated by rustfmt **/*.rs.bk @@ -13,6 +9,13 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# rustc will dump stack traces when hitting an internal compiler error to PWD +rustc-ice-*.txt + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -32,3 +35,10 @@ build_rs_cov.profraw # unit tests test_data/ + +# web client +crates/client-web/node_modules/ +crates/client-web/dist/ + +# dev temp files +.dev/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4a37930c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "packaging/linux/flatpak/deps/flatpak-builder-tools"] + path = packaging/linux/flatpak/deps/flatpak-builder-tools + url = https://github.com/flatpak/flatpak-builder-tools.git + branch = master +[submodule "packaging/linux/flatpak/deps/shared-modules"] + path = packaging/linux/flatpak/deps/shared-modules + url = https://github.com/flathub/shared-modules.git + branch = master diff --git a/.rustfmt.toml b/.rustfmt.toml index af9e6d01..1a371e27 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -6,7 +6,7 @@ error_on_unformatted = true fn_params_layout = "Vertical" format_code_in_doc_comments = true format_strings = true -imports_layout = "HorizontalVertical" +imports_layout = "Vertical" normalize_comments = true normalize_doc_attributes = true reorder_impl_items = true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..30e2617e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Always use `cargo +nightly fmt` when formatting rust code. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5b69f3a8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8546 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78412fa53e6da95324e8902c3641b3ff32ab45258582ea997eb9169c68ffa219" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c6349ddff23194f8fdce2ea8849380f5a4868c1648965b70e801e104cba9b3" +dependencies = [ + "base64 0.22.1", + "jni", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "antithesis_sdk" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dbd97a5b6c21cc9176891cf715f7f0c273caf3959897f43b9bd1231939e675" +dependencies = [ + "libc", + "libloading 0.8.9", + "linkme", + "once_cell", + "rand 0.8.5", + "rustc_version_runtime", + "serde", + "serde_json", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assoc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.0", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", + "tokio", +] + +[[package]] +name = "async-io" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.4.0", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.3.1", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.0", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "av1-grain" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bcrypt" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.3.3", + "subtle", + "zeroize", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", + "which", +] + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel 2.3.1", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "branches" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e426eb5cc1900033930ec955317b302e68f19f326cc7bb0c8a86865a826cdf0c" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122ec45a44b270afd1402f351b782c676b173e3c3fb28d86ff7ebfb4d86a4ee4" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981a6f317983eec002839b90fae7411a85621410ae591a9cab2ecf5cb5744873" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595aae20e65c3be792d05818e8c63025294ac3cb7e200f11459063a352a6ef80" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "pathdiff", + "ron 0.8.1", + "rust-ini", + "serde", + "serde_json", + "toml", + "winnow 0.7.13", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + +[[package]] +name = "db-keystore" +version = "0.4.2-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d682fd95a70cd0de8dd61a95cbd5fe16c9411cdd335864bcce58552f3df6c4" +dependencies = [ + "anyhow", + "clap", + "futures", + "keyring-core", + "log", + "regex", + "serde", + "serde_json", + "turso", + "uuid", + "zeroize", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags 2.11.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel" +version = "2.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" +dependencies = [ + "diesel_derives", + "libsqlite3-sys", + "r2d2", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d4216021b3ea446fd2047f5c8f8fe6e98af34508a254a01e4d6bc1e844f84d" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel_migrations" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +dependencies = [ + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dsl_auto_type" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +dependencies = [ + "darling 0.20.11", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.0", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.3", + "libm", + "siphasher", +] + +[[package]] +name = "fastdivide" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "colored", + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.0", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows 0.48.0", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.2.0", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.3.1", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.38", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "num-traits", + "png 0.17.16", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d75c7014ddab93c232bc6bb9f64790d3dfd1d605199acd4b40b6d69e691e9f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" + +[[package]] +name = "imohash" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8633583228dc22d6163981c851d8dfd56f87d98e99b9c27a5f979454965f5a" +dependencies = [ + "murmur3", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-uring" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc17b941cdab9063edc0f241dbe10ba9895a8b3f92aa908fd4b3533fea2ae841" +dependencies = [ + "android-native-keyring-store", + "apple-native-keyring-store", + "base64 0.22.1", + "clap", + "db-keystore", + "dbus-secret-service-keyring-store", + "keyring-core", + "linux-keyutils-keyring-store", + "rpassword", + "rprompt", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "chrono", + "dashmap", + "log", + "regex", + "ron 0.12.1", + "serde", + "uuid", +] + +[[package]] +name = "koko" +version = "0.0.0" +dependencies = [ + "async-std", + "base64 0.22.1", + "bcrypt", + "cargo_metadata", + "chrono", + "config", + "diesel", + "diesel_migrations", + "dirs", + "fern", + "image", + "imohash", + "jsonwebtoken", + "keyring", + "keyring-core", + "libsqlite3-sys", + "log", + "objc2-core-foundation", + "once_cell", + "rand 0.9.4", + "rcgen", + "regex", + "reqwest 0.13.2", + "rocket", + "rocket_okapi", + "rocket_sync_db_pools", + "rstest", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "strsim 0.11.1", + "tao", + "tmdb_client", + "tokio", + "tray-icon", + "tvdb4", + "webbrowser", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "linux-keyutils-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b" +dependencies = [ + "keyring-core", + "linux-keyutils", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator 0.7.5", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator 0.8.8", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.0", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "measure_time" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +dependencies = [ + "log", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "migrations_internals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mimalloc" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.3.1", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "tokio-util", + "version_check", +] + +[[package]] +name = "murmur3" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" + +[[package]] +name = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "okapi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64853d7ab065474e87696f7601cee817d200e86c42e04004e005cb3e20c3c5" +dependencies = [ + "log", + "schemars 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "ownedbytes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.17", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.27", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls 0.23.38", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls 0.23.38", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-tls 0.6.0", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.38", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rocket" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" +dependencies = [ + "async-stream", + "async-trait", + "atomic 0.5.3", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap 2.12.0", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot", + "pin-project-lite", + "rand 0.8.5", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +dependencies = [ + "devise", + "glob", + "indexmap 2.12.0", + "proc-macro2", + "quote", + "rocket_http", + "syn 2.0.117", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +dependencies = [ + "cookie", + "either", + "futures", + "http 0.2.12", + "hyper 0.14.32", + "indexmap 2.12.0", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "smallvec", + "stable-pattern", + "state", + "time", + "tokio", + "tokio-rustls 0.24.1", + "uncased", +] + +[[package]] +name = "rocket_okapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074297bec35db2fc7ebb6ade6a955b5566de66f83d9af5b5602a350a71bdef43" +dependencies = [ + "log", + "okapi", + "rocket", + "rocket_okapi_codegen", + "schemars 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "rocket_okapi_codegen" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9519ac276544ae734c067b57745cc1a0dc9506f3a7625918e89babffd9b101" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "rocket_http", + "syn 1.0.109", +] + +[[package]] +name = "rocket_sync_db_pools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83f32721ed79509adac4328e97f817a8f55a47c4b64799f6fd6cc3adb6e42ff" +dependencies = [ + "diesel", + "r2d2", + "rocket", + "rocket_sync_db_pools_codegen", + "serde", + "tokio", + "version_check", +] + +[[package]] +name = "rocket_sync_db_pools_codegen" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc890925dc79370c28eb15c9957677093fdb7e8c44966d189f38cedb995ee68" +dependencies = [ + "devise", + "quote", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.11.1", + "serde", + "serde_derive", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags 2.11.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rpassword" +version = "7.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rprompt" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69abf524bb9ccb7c071f7231441288d74b48d176cb309eb00e6f77d186c6e035" +dependencies = [ + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.12", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.38", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.12", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.16", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator 0.8.8", + "hex", + "owo-colors", + "rand 0.8.5", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "sketches-ddsketch" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "softaes" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef461faaeb36c340b6c887167a9054a034f6acfc50a014ead26a02b4356b3de" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom 0.5.6", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tantivy" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" +dependencies = [ + "aho-corasick", + "arc-swap", + "base64 0.22.1", + "bitpacking", + "bon", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "datasketches", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "itertools 0.14.0", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash 2.1.2", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror 2.0.17", + "time", + "typetag", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools 0.14.0", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" +dependencies = [ + "fnv", + "nom", + "ordered-float", + "serde", + "serde_json", +] + +[[package]] +name = "tantivy-sstable" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606" +dependencies = [ + "futures-util", + "itertools 0.14.0", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd", +] + +[[package]] +name = "tantivy-stacker" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951" +dependencies = [ + "murmurhash32", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98" +dependencies = [ + "serde", +] + +[[package]] +name = "tao" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tmdb_client" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75279abdf2a0a6e9ed6c0143bc6d757388d9d8b6bb05202dd750499da1ff6b61" +dependencies = [ + "chrono", + "reqwest 0.12.28", + "serde", + "serde_derive", + "serde_json", + "serde_with", + "thiserror 2.0.17", + "url", +] + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.38", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.12.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.13", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "turso" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d2b15cdb187937ab19358de588c046a44a443273371c2188a1a85e446dd30d" +dependencies = [ + "mimalloc", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec3bdfef4f19e78313075b087e20bc0a394183de419d558b55127082e1f4c7d" +dependencies = [ + "aegis", + "aes", + "aes-gcm", + "antithesis_sdk", + "arc-swap", + "bigdecimal", + "bitflags 2.11.1", + "branches", + "bumpalo", + "bytemuck", + "cfg_aliases", + "cfg_block", + "chrono", + "crc32c", + "crossbeam-skiplist", + "either", + "fallible-iterator", + "fastbloom", + "hex", + "intrusive-collections", + "io-uring", + "libc", + "libloading 0.8.9", + "libm", + "loom 0.7.2", + "miette", + "num-bigint", + "num-traits", + "pack1", + "parking_lot", + "pastey", + "polling", + "rand 0.9.4", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.2", + "rustix 1.1.4", + "ryu", + "serde_json", + "shuttle", + "simsimd", + "smallvec", + "strum", + "strum_macros", + "tantivy", + "tempfile", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "turso_ext" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4daae0f4dd70b1b1787e02629f50565b5c6bcc588a8040f0422a0eee9656d1" +dependencies = [ + "chrono", + "getrandom 0.3.3", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca5bf5eb330b3ca77e1009d68d28543fd6947ff07dbd72653b5d0df5053ec86" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "turso_parser" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9d64dcda49199419415b1480bc2721d02c75b621d490d9ae05112c28f9233" +dependencies = [ + "bitflags 2.11.1", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror 2.0.17", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb3ddbd87be783182d5e7b48bb371d73f2eaa402bdb60f605cd341c2fb9f040" +dependencies = [ + "bindgen", + "env_logger", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06853f2b6ed860cb3908ab16a66b7fb349a884086e6233f4e9942fb63a73af1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "turso_sync_engine" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f057d028ab25b19a9a610da6069dce52a82634d5db4b21d7707b4cccc565f07" +dependencies = [ + "base64 0.22.1", + "bytes", + "genawaiter", + "http 1.3.1", + "libc", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.6.0-pre.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6efc4af637dba3f8c8f0c325d92d53e87b8944f565aeda10538970430b5fa" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "tvdb4" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5e0f6156bab1961483f20bd2678d2caed2764121ef4e512107f6023b772931" +dependencies = [ + "reqwest 0.11.27", + "serde", + "serde_derive", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.4", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +dependencies = [ + "serde", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.4", + "indexmap 2.12.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xtask" +version = "0.0.0" + +[[package]] +name = "yaml-rust2" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce2a4ff45552406d02501cea6c18d8a7e50228e7736a872951fe2fe75c91be7" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.0", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6fe2e33d02a98ee64423802e16df3de99c43e5cf5ff983767e1128b394c8ac" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.2", +] diff --git a/Cargo.toml b/Cargo.toml index 7f6f089f..f3cac5d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/server", + "xtask", # "crates/common", # "crates/clients-*", ] @@ -34,3 +35,12 @@ publish = false # disable publishing to crates.io # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses async-std = { version = "1.13.0", features = ["attributes", "tokio1"] } rstest = "0.25.0" + +[workspace.metadata.ci] +cargo-run-bin = "1.7.4" + +[workspace.metadata.bin] +cargo-deny = { version = "0.19.9", locked = true } +cargo-edit = { version = "0.13.1", bins = ["cargo-set-version"], locked = true } +cargo-tarpaulin = { version = "0.35.4", locked = true } +cargo-wix = { version = "0.3.9", git = "https://github.com/volks73/cargo-wix.git", rev = "fde983c2e901970267e76b8fd68120fdd5457a57", locked = true } diff --git a/LICENSE b/LICENSE index a0990367..5b820333 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,217 @@ -TBD + LIZARDBYTE SOURCE-AVAILABLE LICENSE + Version 1.0, May 2026 + +Copyright (C) 2026 David Lane. +The Licensor may modify this license document at any time and for any reason. +Everyone else is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + PREAMBLE + +The LizardByte Source-Available License ("LB-SAL") is designed to make +source code available for review and upstream contribution while reserving +all rights not expressly granted by this License. This License is not an open +source license. + + TERMS AND CONDITIONS + +1. Definitions + + "License" refers to this document. + + "Licensor" refers to the copyright owner identified in the copyright notice + above and any successor or assignee who owns the rights licensed under this + License. + + "LizardByte" refers to the Licensor's projects, products, services, + repositories, websites, and official contribution channels operated under the + LizardByte name or under any successor name designated by the Licensor. + + "Software" refers to the copyrightable work that includes a notice stating + that it is licensed under this License. + + "Official Project" means the Software project controlled by the Licensor to + which the Software belongs, including the Licensor's official repository, + issue tracker, pull request system, mailing list, or other contribution + channel designated by the Licensor. + + "Contribution" means any modification, addition, patch, proposal, or other + work of authorship intentionally submitted to the Licensor through an Official + Project for possible inclusion in the Software. + + "Derivative Work" means any work based upon, derived from, adapted from, + translated from, modified from, or incorporating the Software or any portion + of the Software, except for a Contribution made solely as permitted by this + License. + + "Distribute" means to sell, rent, lease, lend, publish, sublicense, transfer, + provide, transmit, host, display, perform, make available, or otherwise give + access to the Software or any Derivative Work to any third party, whether by + traditional distribution channels, digital transmission, Software-as-a- + Service, managed service, hosted service, cloud service, streaming, network + access, remote execution, containerization, virtualization, API access, or any + other method now known or later developed. + + "Commercial Purpose" means any purpose intended for or directed toward + commercial advantage, monetary compensation, revenue generation, paid + services, business operations, or other exchange of value, whether direct or + indirect. + + "Competitive Purpose" means any purpose that develops, tests, supports, + markets, offers, operates, or makes available any product, service, feature, + repository, or project that substitutes for, competes with, or is intended to + reduce demand for any LizardByte project, product, or service. + + "Circumvention" means any act of bypassing, removing, deactivating, + defeating, impairing, or otherwise circumventing technological measures, + licensing logic, payment logic, access controls, feature restrictions, or + business logic that controls access to or enforces limitations on use of the + Software, including any paid or premium features. + +2. Grant of Rights + + Subject to the terms and conditions of this License, the Licensor grants you + a non-exclusive, worldwide, royalty-free, non-sublicensable, non-transferable + license to: + + a. View and inspect the Software for personal, non-commercial purposes. + + b. Run unmodified copies of the Software for personal, non-commercial + evaluation purposes only. + + c. Download, copy, or fork the Software only to the extent necessary to + exercise the rights granted in Sections 2(a), 2(b), and 2(d). + + d. Modify the Software only to the extent necessary to prepare and submit + Contributions to the Official Project, provided that any modification, patch, + branch, fork, or copy is created and used solely for that contribution + purpose. + + e. Submit Contributions to the Licensor through the Official Project. + +3. Restrictions + + Any rights not expressly granted in Section 2 are reserved by the Licensor. + Without limiting that reservation of rights, you may not: + + a. Modify the Software except solely as necessary to prepare a Contribution + that you submit, or in good faith intend to submit, to the Official Project. + You may not create or maintain a private, public, internal, or external fork, + branch, patch set, or Derivative Work for any other purpose. If a + Contribution is rejected, withdrawn, abandoned, or no longer intended for + submission to the Official Project, you must promptly delete, destroy, or + revert all related modifications and Derivative Works. + + b. Distribute the Software or any Derivative Work to any third party by any + means, regardless of whether the Distribution is commercial, non-commercial, + educational, individual, charitable, internal, public, private, or otherwise. + + c. Provide or make available the Software or any Derivative Work as a service, + including but not limited to Software-as-a-Service (SaaS), managed service, + hosted service, cloud service, streaming service, subscription service, + remote access, API access, or any other service model now known or later + developed. + + d. Use the Software or any Derivative Work for any Commercial Purpose without + the Licensor's prior written permission. + + e. Sell, rent, lease, lend, sublicense, or otherwise exchange the Software or + any Derivative Work for value, whether monetary or otherwise. + + f. Use the Software or any Derivative Work for any Competitive Purpose. + + g. Use the Software or any Derivative Work to train, fine tune, benchmark, or + evaluate any machine learning model, artificial intelligence system, code + generation system, or automated software development service without the + Licensor's prior written permission. + + h. Engage in Circumvention. + + i. Remove, obscure, or alter any copyright, license, attribution, trademark, + patent, or other proprietary notice in the Software. + + j. Use any trade name, trademark, service mark, logo, domain name, product + name, or branding of the Licensor or LizardByte except as required for + reasonable and customary attribution. + + k. Grant any sublicense or other downstream license to the Software or any + Derivative Work. + +4. Contributions + + Contributions are accepted only under the Contributor License Agreement, + assignment agreement, or other written contribution terms required by the + Licensor for the Official Project. If no separate written contribution terms + apply, then by submitting a Contribution you grant the Licensor a perpetual, + worldwide, irrevocable, royalty-free, fully paid-up, transferable, + sublicensable license to reproduce, modify, prepare derivative works of, + display, perform, distribute, commercialize, and otherwise use the + Contribution for any purpose and under any license or terms. + +5. Attribution + + All copies or substantial portions of the Software that you are permitted to + possess under this License must include a copy of this License and the + following attribution notice: + + "Contains software developed by LizardByte (https://app.lizardbyte.dev)." + +6. Third-Party Rights + + This License does not grant permission to use third-party software, content, + trademarks, patents, or other rights that may be included in or used by the + Software. You are responsible for complying with all applicable third-party + terms. + +7. Disclaimer of Warranty + + 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, TITLE, AND NONINFRINGEMENT. IN NO EVENT + SHALL THE LICENSOR, AUTHORS, CONTRIBUTORS, 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, + THE USE OF THE SOFTWARE, OR OTHER DEALINGS IN THE SOFTWARE. + +8. Termination + + This License and all rights granted under it terminate automatically upon any + breach by you of this License. Upon termination, you must immediately cease + all use of the Software and destroy all copies of the Software and Derivative + Works in your possession, custody, or control. Sections 3, 4, 6, 7, 8, 9, and + 10 survive termination. + +9. Legal Enforcement + + The Licensor reserves the right to enforce this License through legal action, + including but not limited to seeking injunctive relief, damages, costs, and + attorney's fees for any violation of these terms, to the maximum extent + permitted by law. + +10. Miscellaneous + + a. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable, and the remaining provisions shall remain in effect. + + b. This License represents the complete agreement concerning the subject + matter of this License and supersedes all prior or contemporaneous oral or + written understandings regarding that subject matter. + + c. This License shall be governed by the laws of the United States and, where + state law applies, the laws of the state in which the Licensor is domiciled, + excluding conflict of law rules. + + d. No waiver of any provision of this License shall be deemed a further or + continuing waiver of such term or any other term. Any waiver must be in + writing and signed by the Licensor. + + e. The Licensor may revise, replace, or modify this License at any time and + for any reason. The Licensor may apply any revised, replaced, or modified + version of this License to any future release, copy, or distribution of the + Software, or may license the Software under any other terms. No revised, + replaced, or modified version changes the terms applicable to a copy of the + Software already received under this version unless the Licensor expressly + states that the revised terms apply and you accept or are otherwise legally + bound by them. This provision does not permit anyone other than the Licensor + to modify this License. diff --git a/assets/Koko.png b/assets/Koko.png index bb422f03..adf1c337 100644 Binary files a/assets/Koko.png and b/assets/Koko.png differ diff --git a/crates/client-web/README.md b/crates/client-web/README.md new file mode 100644 index 00000000..4d851862 --- /dev/null +++ b/crates/client-web/README.md @@ -0,0 +1,56 @@ +# Koko web client + +This is the initial browser client shell for Koko. + +Current scope: + +- configurable API base URL +- automatic mock-data fallback during local development when the server is unavailable +- server capability bootstrap +- media library list +- media item list +- media item detail view +- simple search against the Stage 1 media APIs + +## Development + +Install dependencies: + +```cmd +npm install +``` + +Start the dev server: + +```cmd +npm run dev +``` + +In development mode, the client will automatically fall back to mock data if the Koko server is unavailable. + +Start the dev server in forced mock mode: + +```cmd +npm run dev:mock +``` + +Use a custom backend base URL during development: + +```cmd +set VITE_API_BASE_URL=https://127.0.0.1:9191 +npm run dev +``` + +Build the client: + +```cmd +npm run build +``` + +After building, the Rust server serves the generated bundle from `crates/client-web/dist`. + +Type-check the client: + +```cmd +npm run check +``` diff --git a/crates/client-web/index.html b/crates/client-web/index.html new file mode 100644 index 00000000..f9112921 --- /dev/null +++ b/crates/client-web/index.html @@ -0,0 +1,12 @@ + + + + + + Koko web client + + +
+ + + diff --git a/crates/client-web/package-lock.json b/crates/client-web/package-lock.json new file mode 100644 index 00000000..a2d912b6 --- /dev/null +++ b/crates/client-web/package-lock.json @@ -0,0 +1,1167 @@ +{ + "name": "koko-client-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "koko-client-web", + "version": "0.0.0", + "dependencies": { + "lucide": "^0.468.0" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^7.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lucide": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.468.0.tgz", + "integrity": "sha512-UFbgwji/ZnAV7iTTE4jujyTV7J95AILKyATDUrqOJrMcUGfXvGjw3c1mcuHZUX2oJfkrAGU9KoxkrLQk2jjtiA==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/crates/client-web/package.json b/crates/client-web/package.json new file mode 100644 index 00000000..f57aeb14 --- /dev/null +++ b/crates/client-web/package.json @@ -0,0 +1,20 @@ +{ + "name": "koko-client-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:mock": "set VITE_USE_MOCK_API=true&& vite", + "build": "vite build", + "preview": "vite preview", + "check": "tsc --noEmit" + }, + "dependencies": { + "lucide": "^0.468.0" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^7.1.0" + } +} diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts new file mode 100644 index 00000000..0b566fc2 --- /dev/null +++ b/crates/client-web/src/api.ts @@ -0,0 +1,1285 @@ +import { + addMockLibrary, + createMockUser, + deleteMockMissingItems, + getMockCapabilities, + getMockBootstrap, + getMockHome, + getMockItems, + getMockItem, + getMockItemMetadata, + getMockLibraries, + getMockMetadataProviders, + getMockLogs, + getMockUsers, + getMockPlayback, + getMockPerson, + getMockSystemActivities, + refreshMockLibraryMetadata, + refreshMockItemMetadata, + getMockSettings, + linkMockItemMetadata, + loginMockUser, + removeMockLibrary, + searchMockItemMetadata, + searchMockItems, + updateMockUser, + updateMockPlaybackProgress, + updateMockSettings, + clearMockMetadataCache, + runMockScheduledTask, +} from './mockApi'; + +const REQUEST_TIMEOUT_MS = 15000; + +export interface ServerCapabilities { + app_name: string; + version: string; + server_url: string; + https_enabled: boolean; + libraries_configured: number; + api_versions: string[]; + transcoding: { + ffmpeg: { + available: boolean; + version?: string; + error?: string; + }; + ffprobe: { + available: boolean; + version?: string; + error?: string; + }; + }; +} + +export interface BootstrapUser { + id: number; + username: string; + admin: boolean; + birthday?: string; + profile_image_url?: string; + preferred_metadata_languages: string[]; +} + +export interface AppBootstrapResponse { + has_users: boolean; + current_user?: BootstrapUser; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface TokenResponse { + token: string; +} + +export interface ProfileImageUploadRequest { + mime_type: string; + data_base64: string; +} + +export interface CreateUserRequest { + username: string; + password: string; + pin?: string; + admin: boolean; + birthday?: string; + profile_image_upload?: ProfileImageUploadRequest; + preferred_metadata_languages?: string[]; +} + +export interface UpdateUserRequest { + username: string; + admin: boolean; + birthday?: string; + profile_image_upload?: ProfileImageUploadRequest; + remove_profile_image?: boolean; + preferred_metadata_languages?: string[]; +} + +export interface MediaLibrary { + id: number; + name: string; + path: string; + paths: string[]; + recursive: boolean; + kind: string; + scanner: string; + metadata_providers: string[]; + metadata_language_mode: 'auto' | 'manual'; + metadata_languages: string[]; + status: string; + scan_revision: number; + last_scanned_at?: number; + total_files: number; + video_files: number; + audio_files: number; + image_files: number; + book_files: number; + other_files: number; + metadata_refresh_total: number; + metadata_refresh_pending: number; + metadata_refresh_completed: number; + metadata_refresh_failed: number; + missing_files: number; + missing_items: number; + error?: string; +} + +export interface MediaItemSummary { + id: number; + library_id: number; + parent_id?: number | null; + item_type: string; + display_title: string; + display_subtitle?: string | null; + artwork_item_id?: number | null; + relative_path: string; + media_kind: string; + playable: boolean; + child_count: number; + available_season_count?: number | null; + season_number?: number; + episode_number?: number; + duration_ms?: number; + width?: number; + height?: number; + genres: string[]; + overview?: string; + backdrop_url?: string; + logo_url?: string; + has_metadata?: boolean; + metadata_refresh_state?: string; + metadata_refresh_error?: string; + artwork_updated_at?: number; + modified_at?: number; + playback_position_ms?: number; + playback_duration_ms?: number; + playback_completed?: boolean; + watch_count?: number; + last_watched_at?: number | null; + missing_since?: number | null; + hierarchy?: MediaItemSummary[]; +} + +export interface MediaPlaybackTarget { + item_id: number; + start_ms: number; + label: string; + display_title: string; + season_number?: number | null; + episode_number?: number | null; + resume: boolean; +} + +export interface MediaItemDetail extends MediaItemSummary { + file_size?: number; + container?: string; + bit_rate?: number; + video_codec?: string; + audio_codec?: string; + metadata_json?: string; + metadata_updated_at?: number; + poster_url?: string; + backdrop_url?: string; + theme_song_url?: string; + tagline?: string; + overview?: string; + genres: string[]; + release_year?: number; + logo_url?: string; + rating?: number; + content_rating?: string; + linked_media_type?: string; + trailer_title?: string; + trailer_url?: string; + extras: MediaItemExtra[]; + artwork_updated_at?: number; + playback_position_ms?: number; + playback_duration_ms?: number; + playback_completed?: boolean; + watch_count?: number; + last_watched_at?: number | null; + playback_target?: MediaPlaybackTarget | null; + restart_playback_target?: MediaPlaybackTarget | null; + audio_tracks: MediaAudioTrack[]; + subtitle_tracks: MediaSubtitleTrack[]; + hierarchy: MediaItemSummary[]; + children: MediaItemSummary[]; +} + +export interface MediaItemExtra { + extra_type: string; + title?: string; + url: string; + duration_seconds?: number; + thumbnail_url?: string; +} + +export interface MediaSubtitleTrack { + index: number; + label: string; + format: string; + url: string; +} + +export interface MetadataProviderStatus { + id: string; + display_name: string; + description: string; + supported_kinds: string[]; + requires_api_key: boolean; + implemented: boolean; + role: 'primary' | 'secondary'; + extends_provider_ids: string[]; + enabled: boolean; + configured: boolean; + language: string; + attribution_text: string; + attribution_url: string; + logo_light_url?: string; + logo_dark_url?: string; +} + +export interface ItemMetadataMatch { + id: number; + provider_id: string; + external_id: string; + title?: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + release_year?: number; + media_type?: string; + relation_kind: string; + match_state: string; + logo_url?: string; + cached_logo_path?: string; + genres: string[]; + people: ItemMetadataPerson[]; + rating?: number; + content_rating?: string; + trailer_title?: string; + trailer_url?: string; + theme_song_url?: string; + locale_key: string; + provider_locale_key?: string; + cached_artwork_path?: string; + cached_backdrop_path?: string; + refresh_state?: string; + last_refreshed_at?: number; + next_refresh_at?: number; + refresh_error?: string; + updated_at?: number; +} + +export interface ItemMetadataPerson { + id: number; + person_id: number; + external_id?: string; + locale_key?: string; + name: string; + role?: string; + department?: string; + character_name?: string; + profile_url?: string; + image_url?: string; + cached_image_path?: string; + sort_order: number; +} + +export interface MetadataPersonSummary { + id: number; + provider_id: string; + external_id?: string; + locale_key: string; + name: string; + known_for: string[]; + biography?: string; + gender?: string; + birthday?: string; + deathday?: string; + birth_place?: string; + profile_url?: string; + image_url?: string; + cached_image_path?: string; + updated_at?: number; +} + +export interface MetadataPersonCreditSummary { + id: number; + metadata_link_id: number; + media_item_id: number; + role?: string; + department?: string; + character_name?: string; + sort_order: number; +} + +export interface MetadataPersonItemCredit { + credit: MetadataPersonCreditSummary; + item: MediaItemSummary; + hierarchy: MediaItemSummary[]; +} + +export interface MetadataPersonResponse { + person: MetadataPersonSummary; + credits: MetadataPersonItemCredit[]; +} + +export interface ItemMetadataResponse { + item_id: number; + providers: MetadataProviderStatus[]; + matches: ItemMetadataMatch[]; +} + +export interface MetadataSearchResult { + provider_id: string; + external_id: string; + media_type: string; + title: string; + overview?: string; + artwork_url?: string; + cached_backdrop_path?: string; + refresh_state?: string; + last_refreshed_at?: number; + next_refresh_at?: number; + refresh_error?: string; + updated_at?: number; +} + +export interface MediaAudioTrack { + index: number; + label: string; + codec?: string; + language?: string; + default: boolean; +} + +export interface ItemMetadataResponse { + item_id: number; + providers: MetadataProviderStatus[]; + matches: ItemMetadataMatch[]; +} + +export interface MetadataSearchResult { + provider_id: string; + external_id: string; + media_type: string; + title: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + release_year?: number; + score?: number; +} + +export interface MediaShelf { + id: string; + title: string; + items: MediaItemSummary[]; +} + +export interface MediaCollectionSummary { + id: string; + provider_id: string; + external_id: string; + name: string; + overview?: string; + artwork_url?: string; + backdrop_url?: string; + theme_song_url?: string; + item_ids: number[]; + item_count: number; +} + +export interface MediaPlaylistSearchSummary { + id: string; + name: string; + overview?: string; + item_count: number; +} + +export type MediaSearchResult = + | { result_type: 'item'; item: MediaItemSummary } + | { result_type: 'collection'; collection: MediaCollectionSummary } + | { result_type: 'person'; person: MetadataPersonSummary } + | { result_type: 'playlist'; playlist: MediaPlaylistSearchSummary }; + +export interface MediaHome { + library_id?: number; + shelves: MediaShelf[]; + collections: MediaCollectionSummary[]; +} + +export interface PlaybackDecision { + item_id: number; + can_direct_play: boolean; + transcode_required: boolean; + reason: string; + stream_url?: string; + mime_type?: string; + transcode_container?: string; + transcode_video_codec?: string; + transcode_audio_codec?: string; + video_transcode_required: boolean; + audio_transcode_required: boolean; + source_video_codec?: string; + source_audio_codec?: string; + source_container?: string; +} + +export interface ClientProfile { + client_type: string; + client_name: string; + supported_containers: string[]; + supported_video_codecs: string[]; + supported_audio_codecs: string[]; + supported_subtitle_formats: string[]; + max_video_width: number; + max_video_height: number; + max_bitrate_kbps: number; + supports_adaptive_streaming: boolean; + prefer_hls: boolean; +} + +export function getWebClientProfile(): ClientProfile { + const videoProbe = document.createElement('video'); + const audioProbe = document.createElement('audio'); + const supportsVideoType = (type: string): boolean => videoProbe.canPlayType(type) === 'probably'; + const supportsAudioType = (type: string): boolean => audioProbe.canPlayType(type) !== ''; + const supportedContainers = new Set(); + const supportedVideoCodecs = new Set(); + const supportedAudioCodecs = new Set(); + + if (supportsVideoType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"')) { + supportedContainers.add('mp4'); + supportedContainers.add('m4v'); + supportedVideoCodecs.add('h264'); + supportedAudioCodecs.add('aac'); + } + if (supportsVideoType('video/mp4; codecs="hvc1.1.6.L93.B0, mp4a.40.2"')) { + supportedContainers.add('mp4'); + supportedContainers.add('m4v'); + supportedVideoCodecs.add('hevc'); + supportedAudioCodecs.add('aac'); + } + if (supportsVideoType('video/mp4; codecs="av01.0.05M.08, mp4a.40.2"')) { + supportedContainers.add('mp4'); + supportedVideoCodecs.add('av1'); + supportedAudioCodecs.add('aac'); + } + if (supportsVideoType('video/webm; codecs="vp8, vorbis"')) { + supportedContainers.add('webm'); + supportedVideoCodecs.add('vp8'); + supportedAudioCodecs.add('vorbis'); + } + if (supportsVideoType('video/webm; codecs="vp9, opus"')) { + supportedContainers.add('webm'); + supportedVideoCodecs.add('vp9'); + supportedAudioCodecs.add('opus'); + } + if (supportsVideoType('video/webm; codecs="av01.0.05M.08, opus"')) { + supportedContainers.add('webm'); + supportedVideoCodecs.add('av1'); + supportedAudioCodecs.add('opus'); + } + if (supportsAudioType('audio/mpeg')) { + supportedContainers.add('mp3'); + supportedAudioCodecs.add('mp3'); + } + if (supportsAudioType('audio/mp4; codecs="mp4a.40.2"')) { + supportedContainers.add('m4a'); + supportedAudioCodecs.add('aac'); + } + if (supportsAudioType('audio/ogg; codecs="vorbis"')) { + supportedContainers.add('ogg'); + supportedAudioCodecs.add('vorbis'); + } + if (supportsAudioType('audio/ogg; codecs="opus"')) { + supportedContainers.add('ogg'); + supportedAudioCodecs.add('opus'); + } + if (supportsAudioType('audio/wav')) { + supportedContainers.add('wav'); + } + if (supportsAudioType('audio/flac')) { + supportedContainers.add('flac'); + supportedAudioCodecs.add('flac'); + } + + return { + client_type: 'web', + client_name: `Koko Web (${navigator.userAgent.split(' ').pop()})`, + supported_containers: [...supportedContainers], + supported_video_codecs: [...supportedVideoCodecs], + supported_audio_codecs: [...supportedAudioCodecs], + supported_subtitle_formats: ['vtt'], + max_video_width: 0, + max_video_height: 0, + max_bitrate_kbps: 0, + supports_adaptive_streaming: false, + prefer_hls: false, + }; +} + +export interface PlaybackSession { + session_id: string; + item_id: number; + user_id?: number; + client_profile: ClientProfile; + decision: PlaybackDecision; + created_at: number; + audio_stream_index?: number; +} + +export interface CreateSessionRequest { + item_id: number; + client_profile: ClientProfile; +} + +export interface MetadataProviderSettings { + id: string; + enabled: boolean; + api_key?: string | null; + api_key_secret_ref?: string | null; + api_key_configured?: boolean; + clear_api_key?: boolean; + language: string; + rate_limit_per_second: number; + retry_attempts: number; + retry_backoff_ms: number; +} + +export interface SystemActivity { + id: string; + category: string; + scope: string; + source: string; + state: string; + label: string; + provider_id?: string; + library_id?: number; + root_item_id?: number; + item_ids: number[]; + total_items: number; + completed_items: number; + failed_items: number; + queued_at: number; + started_at?: number; + updated_at: number; +} + +export interface SystemActivitiesResponse { + generated_at: number; + activities: SystemActivity[]; +} + +export interface LogEntry { + timestamp: string; + level: string; + module: string; + source_file_path: string; + line_number?: number; + message: string; +} + +export interface LogEntriesResponse { + log_path: string; + entries: LogEntry[]; +} + +export interface MediaLibrarySettings { + name: string; + path: string; + paths: string[]; + recursive: boolean; + kind: string; + scanner: string; + metadata_providers: string[]; + metadata_language_mode: 'auto' | 'manual'; + metadata_languages: string[]; + allowed_user_ids: number[]; +} + +export interface SettingsSnapshot { + general: { + data_dir: string; + }; + media: { + libraries: MediaLibrarySettings[]; + missing_item_auto_delete_days?: number | null; + }; + metadata: { + providers: MetadataProviderSettings[]; + refresh_interval_days?: number | null; + }; + scheduled_tasks: { + enabled: boolean; + window: { + start_time: string; + stop_time: string; + weekdays: ScheduledTaskWeekday[]; + }; + metadata_refresh: { + enabled: boolean; + }; + trash_cleanup: { + enabled: boolean; + missing_item_auto_delete_days?: number | null; + interval_days: number; + }; + database_maintenance: { + enabled: boolean; + interval_days: number; + }; + }; + server: { + use_https: boolean; + address: string; + port: number; + cert_path: string; + key_path: string; + use_custom_certs: boolean; + }; + ffmpeg: { + ffmpeg_path: string; + ffprobe_path: string; + }; +} + +export type ScheduledTaskWeekday = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; + +export type ScheduledTaskId = 'metadata_refresh' | 'trash_cleanup' | 'database_maintenance'; + +export interface SettingsResponse { + settings: SettingsSnapshot; + settings_path: string; +} + +export interface ScheduledTaskRunResponse { + task_id: ScheduledTaskId; + started: boolean; + message: string; +} + +export interface MetadataCacheClearResponse { + removed_files: number; +} + +export interface MissingItemsCleanupResponse { + library_id: number; + deleted_files: number; + deleted_items: number; + removed_collection_items: number; + library: MediaLibrary; +} + +export interface PlaybackProgressRequest { + position_ms: number; + duration_ms?: number; + completed: boolean; +} + +export interface LinkMetadataRequest { + provider_id: string; + external_id: string; + media_type: string; +} + +export type ApiMode = 'live' | 'mock'; + +const LOCAL_STORAGE_KEY = 'koko-client-web-api-base'; +const AUTH_TOKEN_STORAGE_KEY = 'koko-client-web-auth-token'; +const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim(); +const ENV_USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API === 'true'; +let activeApiMode: ApiMode = ENV_USE_MOCK_API ? 'mock' : 'live'; + +export function getStoredApiBase(): string { + if (ENV_API_BASE_URL) { + return ENV_API_BASE_URL.replace(/\/$/, ''); + } + + const stored = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)?.trim(); + if (stored) { + return stored.replace(/\/$/, ''); + } + + return globalThis.location.origin.replace(/\/$/, ''); +} + +export function getStoredAuthToken(): string | undefined { + return globalThis.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim() || undefined; +} + +export function setStoredAuthToken(token: string): void { + globalThis.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token.trim()); +} + +export function clearStoredAuthToken(): void { + globalThis.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); +} + +export function getApiMode(): ApiMode { + return activeApiMode; +} + +function shouldUseMockApi(): boolean { + return ENV_USE_MOCK_API || activeApiMode === 'mock'; +} + +function useLiveApi(): void { + activeApiMode = 'live'; +} + +function useMockApi(): void { + activeApiMode = 'mock'; +} + +function getMockGetResponse(method: string, url: URL): T { + switch (url.pathname) { + case '/api/v1/system/capabilities': + return getMockCapabilities() as T; + case '/api/v1/bootstrap': + return getMockBootstrap() as T; + case '/api/v1/users': + return getMockUsers() as T; + case '/api/v1/libraries': + return getMockLibraries() as T; + case '/api/v1/metadata/providers': + return getMockMetadataProviders() as T; + case '/api/v1/system/activities': + return getMockSystemActivities() as T; + case '/api/v1/settings': + return getMockSettings() as T; + case '/api/v1/settings/logs': + return getMockLogs( + url.searchParams.get('level') ?? undefined, + url.searchParams.get('module') ?? undefined, + url.searchParams.get('search') ?? undefined, + url.searchParams.get('since') ?? undefined, + url.searchParams.get('until') ?? undefined, + url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined, + ) as T; + case '/api/v1/home': + return getMockHome(optionalNumericSearchParam(url, 'library_id')) as T; + case '/api/v1/items': + return getMockItems(optionalNumericSearchParam(url, 'library_id')) as T; + case '/api/v1/search': + return searchMockItems(url.searchParams.get('query') ?? '') as T; + default: + return getMockDynamicGetResponse(method, url); + } +} + +function optionalNumericSearchParam(url: URL, name: string): number | undefined { + const value = url.searchParams.get(name); + return value ? Number(value) : undefined; +} + +function getMockDynamicGetResponse(method: string, url: URL): T { + const itemMetadataSearchMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/search$/.exec(url.pathname); + if (itemMetadataSearchMatch) { + return searchMockItemMetadata( + Number(itemMetadataSearchMatch[1]), + url.searchParams.get('query') ?? undefined, + ) as T; + } + + const itemMetadataMatch = /^\/api\/v1\/items\/(\d+)\/metadata$/.exec(url.pathname); + if (itemMetadataMatch) { + const itemMetadata = getMockItemMetadata(Number(itemMetadataMatch[1])); + if (!itemMetadata) { + throw new Error('404 Not Found'); + } + + return itemMetadata as T; + } + + const itemPlaybackMatch = /^\/api\/v1\/items\/(\d+)\/playback$/.exec(url.pathname); + if (itemPlaybackMatch) { + return getMockPlayback(Number(itemPlaybackMatch[1])) as T; + } + + const personMatch = /^\/api\/v1\/people\/(\d+)$/.exec(url.pathname); + if (personMatch) { + return getMockPerson(Number(personMatch[1])) as T; + } + + const itemMatch = /^\/api\/v1\/items\/(\d+)$/.exec(url.pathname); + if (itemMatch) { + const item = getMockItem(Number(itemMatch[1])); + if (!item) { + throw new Error('404 Not Found'); + } + + return item as T; + } + + const sessionStreamMatch = /^\/api\/v1\/sessions\/([^/]+)\/stream$/.exec(url.pathname); + if (sessionStreamMatch) { + throw new Error('501 Not Implemented (mock streaming not fully supported)'); + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function getMockPutResponse(method: string, url: URL, body?: unknown): T { + if (url.pathname === '/api/v1/settings') { + return updateMockSettings(body as SettingsSnapshot) as T; + } + + const updateUserMatch = /^\/api\/v1\/users\/(\d+)$/.exec(url.pathname); + if (updateUserMatch) { + return updateMockUser(Number(updateUserMatch[1]), body as UpdateUserRequest) as T; + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function getMockPostResponse(method: string, url: URL, body?: unknown): T { + if (url.pathname === '/login') { + return loginMockUser(body as LoginRequest) as T; + } + if (url.pathname === '/create_user') { + return createMockUser(body as CreateUserRequest) as T; + } + if (url.pathname === '/api/v1/settings/libraries') { + return addMockLibrary(body as { library: MediaLibrarySettings }) as T; + } + if (url.pathname === '/api/v1/settings/metadata-cache/clear') { + return clearMockMetadataCache() as T; + } + if (url.pathname === '/api/v1/sessions') { + return createMockPlaybackSessionResponse(body); + } + + const scheduledTaskRunMatch = /^\/api\/v1\/scheduled-tasks\/([^/]+)\/run$/.exec(url.pathname); + if (scheduledTaskRunMatch) { + return runMockScheduledTask(scheduledTaskRunMatch[1] as ScheduledTaskId) as T; + } + + const itemProgressMatch = /^\/api\/v1\/items\/(\d+)\/progress$/.exec(url.pathname); + if (itemProgressMatch) { + updateMockPlaybackProgress(Number(itemProgressMatch[1]), body as PlaybackProgressRequest); + return undefined as T; + } + + const itemLinkMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/link$/.exec(url.pathname); + if (itemLinkMatch) { + return linkMockItemMetadata(Number(itemLinkMatch[1]), body as LinkMetadataRequest) as T; + } + + const itemRefreshMatch = /^\/api\/v1\/items\/(\d+)\/metadata\/refresh$/.exec(url.pathname); + if (itemRefreshMatch) { + return refreshMockItemMetadata(Number(itemRefreshMatch[1])) as T; + } + + const libraryRefreshMatch = /^\/api\/v1\/libraries\/(\d+)\/metadata\/refresh$/.exec(url.pathname); + if (libraryRefreshMatch) { + return refreshMockLibraryMetadata(Number(libraryRefreshMatch[1])) as T; + } + + const libraryScanMatch = /^\/api\/v1\/libraries\/(\d+)\/scan$/.exec(url.pathname); + if (libraryScanMatch) { + return refreshMockLibraryMetadata(Number(libraryScanMatch[1])) as T; + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function createMockPlaybackSessionResponse(body?: unknown): T { + const request = body as CreateSessionRequest; + const item = getMockItem(request.item_id); + const preferredLanguages = getMockBootstrap().current_user?.preferred_metadata_languages ?? ['en-US']; + const audioStreamIndex = item?.audio_tracks?.find((track) => { + const language = track.language?.toLowerCase(); + return language && preferredLanguages.some((preferred) => { + const normalized = preferred.toLowerCase(); + return normalized.startsWith(language) || language.startsWith(normalized.split('-')[0]); + }); + })?.index; + + return { + session_id: 'mock-session-123', + item_id: request.item_id, + client_profile: request.client_profile, + decision: getMockPlayback(request.item_id), + created_at: Date.now(), + audio_stream_index: audioStreamIndex, + } as T; +} + +function getMockDeleteResponse(method: string, url: URL): T { + const removeLibraryMatch = /^\/api\/v1\/settings\/libraries\/(\d+)$/.exec(url.pathname); + if (removeLibraryMatch) { + return removeMockLibrary(Number(removeLibraryMatch[1])) as T; + } + + const missingItemsMatch = /^\/api\/v1\/libraries\/(\d+)\/missing$/.exec(url.pathname); + if (missingItemsMatch) { + return deleteMockMissingItems(Number(missingItemsMatch[1])) as T; + } + + const deleteSessionMatch = /^\/api\/v1\/sessions\/([^/]+)$/.exec(url.pathname); + if (deleteSessionMatch) { + return undefined as T; + } + + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); +} + +function getMockJsonResponse(method: string, path: string, body?: unknown): T { + const url = new URL(path, 'http://koko.local'); + + switch (method) { + case 'GET': + return getMockGetResponse(method, url); + case 'PUT': + return getMockPutResponse(method, url, body); + case 'POST': + return getMockPostResponse(method, url, body); + case 'DELETE': + return getMockDeleteResponse(method, url); + default: + throw new Error(`No mock response is defined for ${method} ${url.pathname}`); + } +} + +function getMockJsonFallback(method: string, path: string, body?: unknown): T { + useMockApi(); + return getMockJsonResponse(method, path, body); +} + +function requestHeaders(body?: unknown): Record { + const headers: Record = {}; + const token = getStoredAuthToken(); + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function fetchJsonResponse(method: string, path: string, body?: unknown): Promise { + const abortController = new AbortController(); + const timeoutHandle = globalThis.setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); + return fetch(`${getStoredApiBase()}${path}`, { + method, + headers: requestHeaders(body), + body: body === undefined ? undefined : JSON.stringify(body), + signal: abortController.signal, + }).finally(() => { + globalThis.clearTimeout(timeoutHandle); + }); +} + +async function responseError(response: Response): Promise { + const responseText = (await response.text()).trim(); + return new Error( + responseText + ? `${response.status} ${response.statusText}: ${responseText}` + : `${response.status} ${response.statusText}`, + ); +} + +async function handleErrorResponse(method: string, path: string, body: unknown, response: Response): Promise { + if (response.status === 401) { + clearStoredAuthToken(); + } + const error = await responseError(response); + if (import.meta.env.DEV) { + return getMockJsonFallback(method, path, body); + } + + throw error; +} + +function handleRequestFailure(method: string, path: string, body: unknown, error: unknown): T { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`); + } + if (import.meta.env.DEV) { + return getMockJsonFallback(method, path, body); + } + + throw error; +} + +async function readJsonResponse(response: Response): Promise { + if (response.status === 204) { + return undefined as T; + } + if (response.headers.get('content-type')?.includes('application/json')) { + return response.json() as Promise; + } + + return undefined as T; +} + +async function requestJson(method: string, path: string, body?: unknown): Promise { + if (shouldUseMockApi()) { + return getMockJsonFallback(method, path, body); + } + + try { + const response = await fetchJsonResponse(method, path, body); + if (!response.ok) { + return handleErrorResponse(method, path, body, response); + } + + useLiveApi(); + return readJsonResponse(response); + } catch (error) { + return handleRequestFailure(method, path, body, error); + } +} + +export function getCapabilities(): Promise { + return requestJson('GET', '/api/v1/system/capabilities'); +} + +export function getAppBootstrap(): Promise { + return requestJson('GET', '/api/v1/bootstrap'); +} + +export function loginUser(request: LoginRequest): Promise { + return requestJson('POST', '/login', request); +} + +export function createUser(request: CreateUserRequest): Promise { + return requestJson('POST', '/create_user', request); +} + +export function getUsers(): Promise { + return requestJson('GET', '/api/v1/users'); +} + +export function updateUser(userId: number, request: UpdateUserRequest): Promise { + return requestJson('PUT', `/api/v1/users/${userId}`, request); +} + +export function getLibraries(): Promise { + return requestJson('GET', '/api/v1/libraries'); +} + +export function getHome(libraryId?: number): Promise { + const query = typeof libraryId === 'number' ? `?library_id=${libraryId}` : ''; + return requestJson('GET', `/api/v1/home${query}`); +} + +export function getItems(libraryId?: number): Promise { + const query = typeof libraryId === 'number' ? `?library_id=${libraryId}` : ''; + return requestJson('GET', `/api/v1/items${query}`); +} + +export async function searchItems(query: string): Promise { + const params = new URLSearchParams({ query }); + + const results = await requestJson('GET', `/api/v1/search?${params.toString()}`); + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery || !'playlists'.includes(normalizedQuery)) { + return results; + } + + return [ + ...results, + { + result_type: 'playlist', + playlist: { + id: 'Playlists', + name: 'Playlists', + overview: 'Playlist creation is planned. Items will appear here when playlists are available.', + item_count: 0, + }, + }, + ]; +} + +export function getItem(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}`); +} + +export function getMetadataProviders(): Promise { + return requestJson('GET', '/api/v1/metadata/providers'); +} + +export function getSystemActivities(): Promise { + return requestJson('GET', '/api/v1/system/activities'); +} + +export function getItemMetadata(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}/metadata`); +} + +export function getPerson(personId: number): Promise { + return requestJson('GET', `/api/v1/people/${personId}`); +} + +export function getPersonImageUrl(personId: number): string { + return `${getStoredApiBase()}/api/v1/people/${personId}/image`; +} + +export interface MetadataSearchOptions { + query?: string; + providers?: string[]; + year?: string; + language?: string; +} + +export function searchItemMetadata(itemId: number, options?: string | MetadataSearchOptions): Promise { + const params = new URLSearchParams(); + const normalizedOptions = typeof options === 'string' ? { query: options } : options; + if (normalizedOptions?.query?.trim()) { + params.set('query', normalizedOptions.query.trim()); + } + if (normalizedOptions?.providers?.length) { + params.set('providers', normalizedOptions.providers.join(',')); + } + if (normalizedOptions?.year?.trim()) { + params.set('year', normalizedOptions.year.trim()); + } + if (normalizedOptions?.language?.trim()) { + params.set('language', normalizedOptions.language.trim()); + } + const suffix = params.toString() ? `?${params.toString()}` : ''; + return requestJson('GET', `/api/v1/items/${itemId}/metadata/search${suffix}`); +} + +export function linkItemMetadata(itemId: number, request: LinkMetadataRequest): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/metadata/link`, request); +} + +export function refreshItemMetadata(itemId: number): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/metadata/refresh`); +} + +export function refreshLibraryMetadata(libraryId: number): Promise { + return requestJson('POST', `/api/v1/libraries/${libraryId}/metadata/refresh`); +} + +export function scanLibrary(libraryId: number): Promise { + return requestJson('POST', `/api/v1/libraries/${libraryId}/scan`); +} + +export function deleteMissingItems(libraryId: number): Promise { + return requestJson('DELETE', `/api/v1/libraries/${libraryId}/missing`); +} + +export function getPlaybackDecision(itemId: number): Promise { + return requestJson('GET', `/api/v1/items/${itemId}/playback`); +} + +export function updatePlaybackProgress(itemId: number, request: PlaybackProgressRequest): Promise { + return requestJson('POST', `/api/v1/items/${itemId}/progress`, request); +} + +export function getSettings(): Promise { + return requestJson('GET', '/api/v1/settings'); +} + +export function getLogs(filters?: { + level?: string; + module?: string; + search?: string; + since?: string; + until?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (filters?.level?.trim()) { + params.set('level', filters.level.trim()); + } + if (filters?.module?.trim()) { + params.set('module', filters.module.trim()); + } + if (filters?.search?.trim()) { + params.set('search', filters.search.trim()); + } + if (filters?.since?.trim()) { + params.set('since', filters.since.trim()); + } + if (filters?.until?.trim()) { + params.set('until', filters.until.trim()); + } + if (typeof filters?.limit === 'number' && Number.isFinite(filters.limit)) { + params.set('limit', String(filters.limit)); + } + + const suffix = params.toString() ? `?${params.toString()}` : ''; + return requestJson('GET', `/api/v1/settings/logs${suffix}`); +} + +export function updateSettings(settings: SettingsSnapshot): Promise { + return requestJson('PUT', '/api/v1/settings', settings); +} + +export function clearMetadataCache(): Promise { + return requestJson('POST', '/api/v1/settings/metadata-cache/clear'); +} + +export function runScheduledTask(taskId: ScheduledTaskId): Promise { + return requestJson('POST', `/api/v1/scheduled-tasks/${taskId}/run`); +} + +export function addLibrary(library: MediaLibrarySettings): Promise { + return requestJson('POST', '/api/v1/settings/libraries', { library }); +} + +export function deleteLibrary(libraryIndex: number): Promise { + return requestJson('DELETE', `/api/v1/settings/libraries/${libraryIndex}`); +} + +export function resolveApiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + return path; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${getStoredApiBase()}${normalizedPath}`; +} + +export function getStreamUrl(itemId: number): string { + return `${getStoredApiBase()}/api/v1/items/${itemId}/stream`; +} + +export function getSessionStreamUrl(sessionId: string, startMs?: number, audioStreamIndex?: number): string { + const params = new URLSearchParams(); + if (typeof startMs === 'number' && Number.isFinite(startMs) && startMs > 0) { + params.set('start_ms', String(Math.max(0, Math.floor(startMs)))); + } + if (typeof audioStreamIndex === 'number' && Number.isFinite(audioStreamIndex) && audioStreamIndex >= 0) { + params.set('audio_stream_index', String(Math.floor(audioStreamIndex))); + } + const suffix = params.toString() ? `?${params.toString()}` : ''; + return `${getStoredApiBase()}/api/v1/sessions/${sessionId}/stream${suffix}`; +} + +export function createPlaybackSession(request: CreateSessionRequest): Promise { + return requestJson('POST', '/api/v1/sessions', request); +} + +export function deletePlaybackSession(sessionId: string): Promise { + return requestJson('DELETE', `/api/v1/sessions/${sessionId}`); +} + +export function getArtworkUrl(itemId: number, kind: 'poster' | 'backdrop' | 'logo' = 'poster', revision?: number): string { + const params = new URLSearchParams({ kind }); + if (typeof revision === 'number') { + params.set('rev', String(revision)); + } + + return `${getStoredApiBase()}/api/v1/items/${itemId}/artwork?${params.toString()}`; +} diff --git a/crates/client-web/src/app.ts b/crates/client-web/src/app.ts new file mode 100644 index 00000000..6e80ede2 --- /dev/null +++ b/crates/client-web/src/app.ts @@ -0,0 +1,864 @@ +/** Coordinates app startup, route-level data loading, and shell rendering. */ +import kokoLogoUrl from '../../../assets/Koko.svg'; +import { createIcons, icons } from 'lucide'; +import { + activeLibraryScanActivities, + activeLibraryPendingRefreshCount, + activeMetadataRefreshActivities, + currentLogFilterRequest, + itemIsMetadataPending, + libraryRefreshProgress, + snapshotJson, +} from './app/activities'; +import { + canManageUsers, + currentUser, + renderLoginScreen, + renderAuthShell, + renderWelcomeScreen, + requiresLogin, + requiresSetup, +} from './app/auth'; +import { setElementHtml as patchElementHtml } from './app/domPatcher'; +import { abortRenderEvents, bindEvents, type AppEventBindingContext } from './app/eventBindings'; +import { escapeHtml } from './app/format'; +import { bindGlobalInputHandlers } from './app/input'; +import { + configurePlaybackController, + renderPlayerOverlay, + syncThemeSongPlayer, +} from './app/playbackController'; +import { defaultHomeTab, parseRoute } from './app/routes'; +import { + activeLibraryId, + homeFeaturePreview, + pageBackdropUrlForHomePreview, + searchResultItems, +} from './app/selectors'; +import { syncVisibleSpinners } from './app/spinners'; +import { state } from './app/state'; +import { + renderHomePage, + renderItemCard, +} from './app/homeView'; +import { + renderItemPage, + renderPersonPage, +} from './app/itemPersonView'; +import { renderSettingsPage } from './app/settingsView'; +import { + renderIcon, + renderUserAvatar, + selectedLibraryIcon, +} from './app/ui'; +import { + clearStoredAuthToken, + getAppBootstrap, + getApiMode, + getArtworkUrl, + getCapabilities, + getHome, + getItem, + getItemMetadata, + getItems, + getLibraries, + getPerson, + getMetadataProviders, + getLogs, + getPlaybackDecision, + getSystemActivities, + getSettings, + getStoredAuthToken, + getUsers, + searchItems, + type MediaLibrary, +} from './api'; + +const app = document.querySelector('#app'); +if (!app) { + throw new Error('Failed to find app container'); +} +const appRoot = app; +let pendingLibraryRefreshHandle: number | undefined; +let pendingMetadataRefreshHandle: number | undefined; +let appStarted = false; + +function expandLazyShelfRowsForPatch(root: ParentNode): void { + root.querySelectorAll('[data-lazy-shelf-id]').forEach((row) => { + const shelfId = row.dataset.lazyShelfId; + const shelf = shelfId ? state.home?.shelves.find((entry) => entry.id === shelfId) : undefined; + const currentRow = shelfId + ? document.querySelector(`[data-lazy-shelf-id="${CSS.escape(shelfId)}"]`) + : undefined; + if (!shelf || !currentRow) { + return; + } + + const currentRenderedCount = Number(currentRow.dataset.lazyRenderedCount ?? currentRow.children.length); + const nextRenderedCount = Number(row.dataset.lazyRenderedCount ?? row.children.length); + const targetCount = Math.min( + shelf.items.length, + Math.max( + Number.isFinite(currentRenderedCount) ? currentRenderedCount : currentRow.children.length, + Number.isFinite(nextRenderedCount) ? nextRenderedCount : row.children.length, + ), + ); + if (targetCount > row.children.length) { + row.insertAdjacentHTML('beforeend', shelf.items.slice(row.children.length, targetCount).map(renderItemCard).join('')); + } + row.dataset.lazyRenderedCount = String(targetCount); + row.dataset.lazyComplete = targetCount >= shelf.items.length ? 'true' : 'false'; + }); +} + +function setElementHtml(root: HTMLElement, html: string, preserveDom = true): void { + patchElementHtml(root, html, { + preserveDom, + abortEvents: abortRenderEvents, + beforePatch: expandLazyShelfRowsForPatch, + }); +} + +function shouldDeferAutoRefreshRender(): boolean { + if (state.route.page !== 'item') { + return false; + } + + if (state.isPlayerOpen || Boolean(state.activeTrailer)) { + return true; + } + + const themeAudio = document.querySelector('#theme-song-player'); + if (themeAudio && !themeAudio.paused && !themeAudio.ended) { + return true; + } + + return Boolean(document.querySelector('#theme-song-youtube-player')); +} + +function maybeRenderAfterAutoRefresh(shouldRender: boolean): void { + if (state.error) { + state.hasDeferredAutoRefreshRender = false; + render(); + return; + } + + if (state.hasDeferredAutoRefreshRender && !shouldAutoRefreshMetadata()) { + state.hasDeferredAutoRefreshRender = false; + render(); + return; + } + + if (!shouldRender) { + return; + } + + if (shouldDeferAutoRefreshRender()) { + state.hasDeferredAutoRefreshRender = true; + return; + } + + state.hasDeferredAutoRefreshRender = false; + render(); +} + +function clearPendingLibraryRefresh(): void { + if (pendingLibraryRefreshHandle !== undefined) { + globalThis.clearTimeout(pendingLibraryRefreshHandle); + pendingLibraryRefreshHandle = undefined; + } +} + +function shouldAutoRefreshLibraries(): boolean { + return state.route.page === 'home' + && state.libraries.some((library) => library.status === 'never_scanned'); +} + +function schedulePendingLibraryRefresh(): void { + clearPendingLibraryRefresh(); + if (!shouldAutoRefreshLibraries()) { + return; + } + + pendingLibraryRefreshHandle = globalThis.setTimeout(() => { + pendingLibraryRefreshHandle = undefined; + void refreshPendingLibraryData(); + }, 1800); +} + +function clearPendingMetadataRefresh(): void { + if (pendingMetadataRefreshHandle !== undefined) { + globalThis.clearTimeout(pendingMetadataRefreshHandle); + pendingMetadataRefreshHandle = undefined; + } +} + +function itemPageMetadataRefreshItemIds(): Set { + const itemIds = new Set(); + if (state.route.page !== 'item' || !state.selectedItem) { + return itemIds; + } + + itemIds.add(state.selectedItem.id); + state.selectedItem.children.forEach((child) => itemIds.add(child.id)); + state.selectedItem.hierarchy.forEach((ancestor) => itemIds.add(ancestor.id)); + return itemIds; +} + +function librariesHavePendingMetadataRefresh(): boolean { + return state.libraries.some((library) => library.metadata_refresh_pending > 0); +} + +function shouldAutoRefreshMetadata(): boolean { + if (activeLibraryScanActivities().length > 0) { + return true; + } + + if (state.route.page === 'settings') { + return false; + } + + if (state.route.page === 'item') { + const itemPageIds = itemPageMetadataRefreshItemIds(); + const hasActiveItemPageRefresh = activeMetadataRefreshActivities() + .some((activity) => activity.item_ids.some((itemId) => itemPageIds.has(itemId))); + + return itemIsMetadataPending(state.selectedItem) + || hasActiveItemPageRefresh + || Boolean(state.selectedItem?.children.some((child) => itemIsMetadataPending(child))) + || Boolean(state.selectedItemMetadata?.matches.some((match) => match.refresh_state === 'pending')); + } + + if (activeMetadataRefreshActivities().length > 0) { + return true; + } + + if (librariesHavePendingMetadataRefresh()) { + return true; + } + + const visibleShelfItems = state.home?.shelves.flatMap((shelf) => shelf.items) ?? []; + return [...state.libraryItems, ...searchResultItems(), ...visibleShelfItems] + .some((item) => item.metadata_refresh_state === 'pending'); +} + +function schedulePendingMetadataRefresh(force = false): void { + clearPendingMetadataRefresh(); + if (!force && !shouldAutoRefreshMetadata()) { + return; + } + + pendingMetadataRefreshHandle = globalThis.setTimeout(() => { + pendingMetadataRefreshHandle = undefined; + void refreshPendingMetadataData(); + }, 1500); +} + +function navigateTo(path: string, replace = false): void { + const currentPath = `${globalThis.location.pathname}${globalThis.location.search}`; + if (currentPath === path) { + state.route = parseRoute(); + render(); + return; + } + + if (replace) { + globalThis.history.replaceState({}, '', path); + } else { + globalThis.history.pushState({}, '', path); + } + state.route = parseRoute(); + if (state.route.page === 'home') { + state.homeTab = defaultHomeTab(state.route); + state.browseFilter = undefined; + } + state.isTrailerMenuOpen = false; + void refreshData(); +} + +function eventBindingContext(): AppEventBindingContext { + return { + navigateTo, + refreshData, + refreshPendingMetadataData, + render, + schedulePendingMetadataRefresh, + setElementHtml, + }; +} + +function renderLibraryRefreshIndicator(library: MediaLibrary): string { + const progress = libraryRefreshProgress(library); + if (!progress) { + return ''; + } + + const stalePending = Math.max(0, library.metadata_refresh_pending - activeLibraryPendingRefreshCount(library.id)); + const tooltipParts = [`Metadata refresh progress: ${progress.completed}/${progress.total}`]; + if (progress.failed > 0) { + tooltipParts.push(`${progress.failed} failed`); + } + if (stalePending > 0) { + tooltipParts.push(`${stalePending} pending without active worker`); + } + const tooltip = tooltipParts.join(' · '); + return ` + + + + `; +} + +function isRailCollapsed(): boolean { + return state.route.page === 'item'; +} + +async function loadLibraryItemsForCurrentRoute(): Promise { + const route = parseRoute(); + if (route.page !== 'home' && route.page !== 'browse-detail') { + return; + } + const libraryId = route.libraryId; + const searchQuery = state.searchQuery.trim(); + state.libraryItemsLoading = true; + render(true); + + try { + const [libraryItems, searchResults] = await Promise.all([ + getItems(libraryId), + searchQuery ? searchItems(searchQuery) : Promise.resolve([]), + ]); + const nextRoute = parseRoute(); + if ( + (nextRoute.page !== 'home' && nextRoute.page !== 'browse-detail') + || nextRoute.libraryId !== libraryId + ) { + return; + } + state.libraryItems = libraryItems; + state.searchResults = searchResults; + state.error = undefined; + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to load library items.'; + } finally { + const nextRoute = parseRoute(); + if ( + (nextRoute.page === 'home' || nextRoute.page === 'browse-detail') + && nextRoute.libraryId === libraryId + ) { + state.libraryItemsLoading = false; + render(true); + } + } +} + +function renderCurrentPage(): string { + switch (state.route.page) { + case 'item': + return renderItemPage(); + case 'person': + return renderPersonPage(); + case 'settings': + return renderSettingsPage(); + default: + return renderHomePage(); + } +} + +function renderRail(): string { + const activeLibraryIdValue = activeLibraryId(); + const collapsed = isRailCollapsed(); + const user = currentUser(); + const userRoleLabel = user?.admin ? 'Administrator' : 'Signed in'; + const userCardMarkup = user + ? ` +
+ ${renderUserAvatar(user, 'rail-avatar')} + + ${escapeHtml(user.username)} + ${userRoleLabel} + +
+ ` + : ''; + + return ` + + `; +} + +function render(preserveScroll = true): void { + if (!state.isPlayerOpen && !state.activeTrailer) { + document.body.style.cursor = ''; + } + + if (!state.bootstrap && state.isLoading) { + setElementHtml(appRoot, renderAuthShell('Loading Koko', 'Checking server state and account access.', ''), preserveScroll); + createIcons({ icons }); + return; + } + + if (requiresSetup()) { + setElementHtml(appRoot, renderWelcomeScreen(), preserveScroll); + createIcons({ icons }); + bindEvents(eventBindingContext()); + return; + } + + if (requiresLogin()) { + setElementHtml(appRoot, renderLoginScreen(), preserveScroll); + createIcons({ icons }); + bindEvents(eventBindingContext()); + return; + } + + const homeFeature = state.route.page === 'home' || state.route.page === 'browse-detail' + ? homeFeaturePreview() + : undefined; + const pageBackdropUrl = state.route.page === 'item' && state.selectedItem + && (state.selectedItem.backdrop_url || state.selectedItemMetadata?.matches.some((match) => Boolean(match.backdrop_url || match.cached_backdrop_path))) + ? getArtworkUrl(state.selectedItem.id, 'backdrop', state.selectedItem.artwork_updated_at) + : pageBackdropUrlForHomePreview(homeFeature); + const railCollapsed = isRailCollapsed(); + const pageBackdropScopeClass = state.route.page === 'home' || state.route.page === 'browse-detail' + ? ' home-page-backdrop' + : ''; + + setElementHtml(appRoot, ` +
+ ${pageBackdropUrl ? `
` : ''} + ${renderRail()} +
+
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${renderCurrentPage()} +
+
+ ${renderPlayerOverlay()} +
+ `, preserveScroll); + + createIcons({ icons }); + bindEvents(eventBindingContext()); + syncVisibleSpinners(); + syncThemeSongPlayer(); +} + +async function refreshData(showLoading = true): Promise { + state.route = parseRoute(); + state.isLoading = true; + state.error = undefined; + state.apiMode = getApiMode(); + if (showLoading) { + render(false); + } + + try { + state.bootstrap = await getAppBootstrap().catch(async (error) => { + if (!getStoredAuthToken()) { + throw error; + } + + clearStoredAuthToken(); + return getAppBootstrap(); + }); + + if (requiresSetup() || requiresLogin()) { + clearPendingLibraryRefresh(); + clearPendingMetadataRefresh(); + state.capabilities = undefined; + state.libraries = []; + state.home = undefined; + state.libraryItems = []; + state.searchResults = []; + state.showFullSearchResults = false; + state.metadataProviders = []; + state.systemActivities = []; + state.dashboardItems = []; + state.settingsResponse = undefined; + state.logsResponse = undefined; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPerson = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.users = []; + state.hasDeferredAutoRefreshRender = false; + return; + } + + const [capabilities, libraries, metadataProviders, settingsResponse, systemActivitiesResponse] = await Promise.all([ + getCapabilities(), + getLibraries(), + getMetadataProviders(), + getSettings(), + getSystemActivities(), + ]); + + state.capabilities = capabilities; + state.libraries = libraries; + state.metadataProviders = metadataProviders; + state.settingsResponse = settingsResponse; + state.systemActivities = systemActivitiesResponse.activities; + state.users = canManageUsers() ? await getUsers() : []; + + if (state.route.page === 'home' || state.route.page === 'browse-detail') { + const libraryId = state.route.libraryId; + const home = await getHome(libraryId); + state.home = home; + state.libraryItems = []; + state.searchResults = []; + state.libraryItemsLoading = true; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPerson = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = ''; + state.metadataSearchProviders = []; + state.isPlayerOpen = false; + state.activePlaybackItem = undefined; + state.activePlaybackSession = undefined; + state.activePlaybackStartMs = 0; + state.activeAudioStreamIndex = undefined; + state.isAudioTrackMenuOpen = false; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.hasDeferredAutoRefreshRender = false; + state.dashboardItems = []; + state.logsResponse = undefined; + void loadLibraryItemsForCurrentRoute(); + } else if (state.route.page === 'item') { + state.home = undefined; + state.libraryItems = []; + state.libraryItemsLoading = false; + state.searchResults = []; + state.showFullSearchResults = false; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = ''; + state.metadataSearchProviders = []; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.dashboardItems = []; + state.logsResponse = undefined; + const [item, metadata, playback] = await Promise.all([ + getItem(state.route.itemId), + getItemMetadata(state.route.itemId), + getPlaybackDecision(state.route.itemId), + ]); + const [home, libraryItems] = await Promise.all([ + getHome(item.library_id), + getItems(item.library_id), + ]); + state.home = home; + state.libraryItems = libraryItems; + state.selectedItem = item; + state.selectedItemMetadata = metadata; + state.selectedPlayback = playback; + state.selectedPerson = undefined; + } else if (state.route.page === 'person') { + state.home = undefined; + state.libraryItems = []; + state.libraryItemsLoading = false; + state.searchResults = []; + state.showFullSearchResults = false; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = ''; + state.metadataSearchProviders = []; + state.isPlayerOpen = false; + state.activePlaybackItem = undefined; + state.activePlaybackSession = undefined; + state.activePlaybackStartMs = 0; + state.activeAudioStreamIndex = undefined; + state.isAudioTrackMenuOpen = false; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.dashboardItems = []; + state.logsResponse = undefined; + state.selectedPerson = await getPerson(state.route.personId); + } else { + state.home = undefined; + state.libraryItems = []; + state.libraryItemsLoading = false; + state.searchResults = []; + state.showFullSearchResults = false; + state.selectedItem = undefined; + state.selectedItemMetadata = undefined; + state.selectedPerson = undefined; + state.selectedPlayback = undefined; + state.metadataSearchResults = []; + state.metadataSearchQuery = ''; + state.metadataSearchYear = ''; + state.metadataSearchLanguage = ''; + state.metadataSearchProviders = []; + state.isPlayerOpen = false; + state.activePlaybackItem = undefined; + state.activePlaybackSession = undefined; + state.activePlaybackStartMs = 0; + state.activeAudioStreamIndex = undefined; + state.isAudioTrackMenuOpen = false; + state.isTrailerMenuOpen = false; + state.activeTrailer = undefined; + state.hasDeferredAutoRefreshRender = false; + if (state.route.section === 'dashboard') { + state.logsResponse = undefined; + state.dashboardItems = await getItems(); + } else if (state.route.section === 'logs') { + state.dashboardItems = []; + state.logsResponse = await getLogs(currentLogFilterRequest()); + } else { + state.dashboardItems = []; + state.logsResponse = undefined; + } + } + + state.apiMode = getApiMode(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to load server data.'; + state.apiMode = getApiMode(); + } finally { + state.isLoading = false; + schedulePendingLibraryRefresh(); + schedulePendingMetadataRefresh(); + render(true); + } +} + +async function refreshPendingLibraryData(): Promise { + const route = parseRoute(); + if (route.page !== 'home') { + return; + } + + let shouldRender = false; + const previousError = state.error; + + try { + const libraryId = route.libraryId; + const searchQuery = state.searchQuery.trim(); + const previousSnapshot = snapshotJson({ + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }); + const [libraries, home, libraryItems, searchResults] = await Promise.all([ + getLibraries(), + getHome(libraryId), + getItems(libraryId), + searchQuery + ? searchItems(searchQuery) + : Promise.resolve([]), + ]); + if (state.route.page !== 'home' || state.route.libraryId !== libraryId) { + return; + } + + state.libraries = libraries; + state.home = home; + state.libraryItems = libraryItems; + state.searchResults = searchResults; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }) || previousError !== state.error; + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh library data.'; + shouldRender = previousError !== state.error; + } finally { + schedulePendingLibraryRefresh(); + maybeRenderAfterAutoRefresh(shouldRender); + } +} + +async function refreshPendingMetadataData(): Promise { + const route = parseRoute(); + let shouldRender = false; + const previousError = state.error; + + try { + if (route.page === 'item') { + const itemId = route.itemId; + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + selectedItem: state.selectedItem, + selectedItemMetadata: state.selectedItemMetadata, + }); + const [activitiesResponse, libraries, item, metadata] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getItem(itemId), + getItemMetadata(itemId), + ]); + if (state.route.page !== 'item' || state.route.itemId !== itemId) { + return; + } + const [home, libraryItems] = await Promise.all([ + getHome(item.library_id), + getItems(item.library_id), + ]); + if (state.route.page !== 'item' || state.route.itemId !== itemId) { + return; + } + + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.home = home; + state.libraryItems = libraryItems; + state.selectedItem = item; + state.selectedItemMetadata = metadata; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + selectedItem: state.selectedItem, + selectedItemMetadata: state.selectedItemMetadata, + }) || previousError !== state.error; + } else if (route.page === 'home') { + const libraryId = route.libraryId; + const searchQuery = state.searchQuery.trim(); + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }); + const [activitiesResponse, libraries, home, libraryItems, searchResults] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getHome(libraryId), + getItems(libraryId), + searchQuery + ? searchItems(searchQuery) + : Promise.resolve([]), + ]); + if (state.route.page !== 'home' || state.route.libraryId !== libraryId) { + return; + } + + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.home = home; + state.libraryItems = libraryItems; + state.searchResults = searchResults; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + home: state.home, + libraryItems: state.libraryItems, + searchResults: state.searchResults, + }) || previousError !== state.error; + } else { + const previousSnapshot = snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + logsResponse: state.logsResponse, + dashboardItems: state.dashboardItems, + }); + const [activitiesResponse, libraries, logsResponse, dashboardItems] = await Promise.all([ + getSystemActivities(), + getLibraries(), + getLogs(currentLogFilterRequest()), + getItems(), + ]); + state.systemActivities = activitiesResponse.activities; + state.libraries = libraries; + state.logsResponse = logsResponse; + state.dashboardItems = dashboardItems; + state.error = undefined; + shouldRender = previousSnapshot !== snapshotJson({ + systemActivities: state.systemActivities, + libraries: state.libraries, + logsResponse: state.logsResponse, + dashboardItems: state.dashboardItems, + }) || previousError !== state.error; + } + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh media metadata.'; + shouldRender = previousError !== state.error; + } finally { + schedulePendingMetadataRefresh(); + maybeRenderAfterAutoRefresh(shouldRender); + } +} + +/** Starts the browser UI, binds global controls, and loads the initial data set. */ +export function startApp(): void { + if (appStarted) { + return; + } + + appStarted = true; + configurePlaybackController({ render, refreshData }); + bindGlobalInputHandlers(state); + + globalThis.addEventListener('popstate', () => { + state.route = parseRoute(); + if (state.route.page === 'home' || state.route.page === 'browse-detail') { + state.homeTab = defaultHomeTab(state.route); + state.browseFilter = undefined; + } + state.isTrailerMenuOpen = false; + void refreshData(); + }); + + render(); + void refreshData(); +} diff --git a/crates/client-web/src/app/activities.ts b/crates/client-web/src/app/activities.ts new file mode 100644 index 00000000..270d84d4 --- /dev/null +++ b/crates/client-web/src/app/activities.ts @@ -0,0 +1,131 @@ +/** Derives active background activity and refresh progress state. */ +import type { MediaItemSummary, MediaLibrary, SystemActivity } from '../api'; +import { state } from './state'; + +export function activeMetadataRefreshActivities(): SystemActivity[] { + return state.systemActivities.filter((activity) => { + return activity.category === 'metadata_refresh' + && activity.state !== 'completed' + && activity.state !== 'failed'; + }); +} + +export function activeLibraryScanActivities(): SystemActivity[] { + return state.systemActivities.filter((activity) => { + return activity.category === 'library_scan' + && activity.state !== 'completed' + && activity.state !== 'failed'; + }); +} + +export function hasActiveLibraryScan(libraryId?: number): boolean { + const activities = activeLibraryScanActivities(); + return libraryId === undefined + ? activities.length > 0 + : activities.some((activity) => activity.library_id === libraryId); +} + +export function activeMetadataRefreshItemIds(): Set { + return new Set(activeMetadataRefreshActivities().flatMap((activity) => activity.item_ids)); +} + +/** Returns whether an item is currently marked for metadata refresh. */ +export function itemIsMetadataPending(item: Pick | undefined): boolean { + return item?.metadata_refresh_state === 'pending'; +} + +export function itemHasActiveMetadataRefresh(item: Pick | undefined): boolean { + return item?.metadata_refresh_state === 'pending' && activeMetadataRefreshItemIds().has(item.id); +} + +/** Calculates stored metadata refresh progress from a library summary. */ +export function libraryRefreshProgress(library: MediaLibrary): { completed: number; total: number; percent: number; failed: number } | undefined { + const activityProgress = metadataRefreshActivityProgressForLibrary(library.id); + if (activityProgress) { + return activityProgress; + } + + if (library.metadata_refresh_total <= 0 || library.metadata_refresh_pending <= 0) { + return undefined; + } + + const completed = Math.max(0, library.metadata_refresh_completed); + const percent = Math.min(100, Math.max(0, (completed / library.metadata_refresh_total) * 100)); + return { + completed, + total: library.metadata_refresh_total, + percent, + failed: library.metadata_refresh_failed, + }; +} + +export function activityProgress(activity: Pick): { + completed: number; + total: number; + failed: number; + percent: number; +} { + const total = Math.max(0, activity.total_items); + const completed = Math.min(total, Math.max(0, activity.completed_items)); + const failed = Math.max(0, activity.failed_items); + const percent = total > 0 ? Math.min(100, Math.max(0, (completed / total) * 100)) : 0; + return { completed, total, failed, percent }; +} + +export function metadataRefreshActivityProgressForLibrary(libraryId: number): { + completed: number; + total: number; + failed: number; + percent: number; +} | undefined { + const activities = activeMetadataRefreshActivities().filter((activity) => activity.library_id === libraryId); + if (!activities.length) { + return undefined; + } + + const totals = activities.reduce((summary, activity) => { + const progress = activityProgress(activity); + return { + completed: summary.completed + progress.completed, + total: summary.total + progress.total, + failed: summary.failed + progress.failed, + }; + }, { completed: 0, total: 0, failed: 0 }); + if (totals.total <= 0) { + return undefined; + } + + return { + ...totals, + percent: Math.min(100, Math.max(0, (totals.completed / totals.total) * 100)), + }; +} + +/** Counts active metadata refresh work items associated with a library. */ +export function activeLibraryPendingRefreshCount(libraryId: number): number { + return activeMetadataRefreshActivities() + .filter((activity) => activity.library_id === libraryId) + .reduce((total, activity) => { + const remaining = Math.max(0, activity.total_items - activity.completed_items - activity.failed_items); + return total + remaining; + }, 0); +} + +export function libraryHasActiveMetadataRefresh(libraryId: number): boolean { + return activeMetadataRefreshActivities().some((activity) => activity.library_id === libraryId); +} + +export function currentLogFilterRequest(): { level?: string; module?: string; search?: string; since?: string; until?: string; limit: number } { + return { + level: state.logFilters.level || undefined, + module: state.logFilters.module || undefined, + search: state.logFilters.search || undefined, + since: state.logFilters.since || undefined, + until: state.logFilters.until || undefined, + limit: 200, + }; +} + +export function snapshotJson(value: unknown): string { + return JSON.stringify(value); +} diff --git a/crates/client-web/src/app/auth.ts b/crates/client-web/src/app/auth.ts new file mode 100644 index 00000000..48b4a081 --- /dev/null +++ b/crates/client-web/src/app/auth.ts @@ -0,0 +1,168 @@ +/** Handles authentication state helpers and authentication screen markup. */ +import kokoLogoUrl from '../../../../assets/Koko.svg'; +import type { BootstrapUser, ProfileImageUploadRequest } from '../api'; +import { escapeHtml } from './format'; +import { state } from './state'; +import { renderButtonContent, renderUserAvatar } from './ui'; + +export function currentUser(): BootstrapUser | undefined { + return state.bootstrap?.current_user; +} + +export function requiresSetup(): boolean { + return state.bootstrap?.has_users === false; +} + +export function requiresLogin(): boolean { + return state.bootstrap?.has_users === true && !currentUser(); +} + +export function canManageUsers(): boolean { + return currentUser()?.admin ?? false; +} + +export async function readProfileImageUpload(formData: FormData): Promise { + const value = formData.get('profile_image_file'); + if (!(value instanceof File) || value.size === 0) { + return undefined; + } + + const allowedTypes = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); + if (!allowedTypes.has(value.type)) { + throw new Error('Profile image must be a JPEG, PNG, WebP, or GIF file.'); + } + if (value.size > 2 * 1024 * 1024) { + throw new Error('Profile image must be 2 MB or smaller.'); + } + + const dataUrl = await readFileAsDataUrl(value); + const dataBase64 = dataUrl.split(',')[1] ?? ''; + if (!dataBase64) { + throw new Error('Failed to read profile image.'); + } + + return { + mime_type: value.type, + data_base64: dataBase64, + }; +} + +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => resolve(typeof reader.result === 'string' ? reader.result : '')); + reader.addEventListener('error', () => reject(new Error('Failed to read profile image.'))); + reader.readAsDataURL(file); + }); +} + +export function renderAuthShell(title: string, description: string, content: string): string { + return ` +
+
+
+
+
+

Koko

+

${escapeHtml(description)}

+
+
+
+

${escapeHtml(title)}

+
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${content} +
+
+ `; +} + +export function renderWelcomeScreen(): string { + return renderAuthShell( + 'Create the first admin user', + 'Koko needs one administrator account before the media library can be used.', + ` +
+ + + + + + +
+ `, + ); +} + +export function renderLoginScreen(): string { + return renderAuthShell( + 'Sign in', + 'Sign in with a Koko account to browse media and keep watch progress per user.', + ` +
+ + + +
+ `, + ); +} + +export function renderUserManagement(): string { + if (!canManageUsers()) { + return ''; + } + + let userListMarkup = '
No users found.
'; + if (state.users.length) { + userListMarkup = state.users.map((user) => { + const adminChecked = user.admin ? 'checked' : ''; + const adminTagClass = user.admin ? 'success' : ''; + const adminTagLabel = user.admin ? 'Admin' : 'User'; + return ` +
+ ${renderUserAvatar(user, 'edit-avatar')} +
+ + + + + + +
+
+ ${adminTagLabel} + +
+
+ `; + }).join(''); + } + + return ` +
+
+

Users

+
+
+ ${userListMarkup} +
+
+ +
+
+
+

Add user

+
+ + + + + + + + +
+
+ `; +} diff --git a/crates/client-web/src/app/constants.ts b/crates/client-web/src/app/constants.ts new file mode 100644 index 00000000..b07c2438 --- /dev/null +++ b/crates/client-web/src/app/constants.ts @@ -0,0 +1,20 @@ +/** Fallback YouTube ID used when a theme-song player must be created before a target video is known. */ +export const YOUTUBE_THEME_PLACEHOLDER_VIDEO_ID = 'dQw4w9WgXcQ'; + +/** Number of cards rendered initially for lazy home shelves. */ +export const HOME_SHELF_CHUNK_SIZE = 12; + +/** Numeric states reported by the YouTube iframe player. */ +export const YOUTUBE_PLAYER_STATE = { + ended: 0, + playing: 1, + paused: 2, + buffering: 3, + cued: 5, +} as const; + +/** Character count after which long descriptions get a disclosure control. */ +export const COLLAPSIBLE_TEXT_LENGTH = 520; + +/** Line count after which long descriptions get a disclosure control. */ +export const COLLAPSIBLE_TEXT_LINE_COUNT = 6; diff --git a/crates/client-web/src/app/dashboardView.ts b/crates/client-web/src/app/dashboardView.ts new file mode 100644 index 00000000..d401bd71 --- /dev/null +++ b/crates/client-web/src/app/dashboardView.ts @@ -0,0 +1,332 @@ +/** Renders metadata dashboard, system activity, and log viewer panels. */ +import type { MediaItemSummary } from '../api'; +import { escapeHtml, formatTimestamp } from './format'; +import { activityProgress, itemHasActiveMetadataRefresh, itemIsMetadataPending } from './activities'; +import { state } from './state'; +import { formatChildCount, humanizeItemType, renderButtonContent } from './ui'; + +export function metadataDashboardRefreshState(item: MediaItemSummary): 'pending' | 'stalled' | 'error' | 'fresh' | 'unmatched' { + if (itemIsMetadataPending(item)) { + return itemHasActiveMetadataRefresh(item) ? 'pending' : 'stalled'; + } + + if (item.metadata_refresh_state === 'error') { + return 'error'; + } + + if (item.metadata_refresh_state === 'fresh' || item.has_metadata) { + return 'fresh'; + } + + return 'unmatched'; +} + +export function metadataDashboardRefreshLabel(item: MediaItemSummary): string { + switch (metadataDashboardRefreshState(item)) { + case 'pending': + return 'Refreshing'; + case 'stalled': + return 'Pending without worker'; + case 'error': + return 'Failed'; + case 'fresh': + return 'Up to date'; + default: + return 'Not linked'; + } +} + +export function filteredMetadataDashboardItems(): MediaItemSummary[] { + const libraryFilter = state.metadataDashboardFilters.libraryId; + const itemTypeFilter = state.metadataDashboardFilters.itemType; + const refreshStateFilter = state.metadataDashboardFilters.refreshState; + const searchFilter = state.metadataDashboardFilters.search.trim().toLowerCase(); + + const rank = (item: MediaItemSummary): number => { + switch (metadataDashboardRefreshState(item)) { + case 'error': + return 0; + case 'stalled': + return 1; + case 'pending': + return 2; + case 'unmatched': + return 3; + default: + return 4; + } + }; + + return [...state.dashboardItems] + .filter((item) => { + const matchesLibrary = libraryFilter ? String(item.library_id) === libraryFilter : true; + const matchesItemType = itemTypeFilter ? item.item_type === itemTypeFilter : true; + const matchesRefreshState = refreshStateFilter ? metadataDashboardRefreshState(item) === refreshStateFilter : true; + const matchesSearch = searchFilter + ? `${item.display_title} ${item.relative_path} ${item.metadata_refresh_error ?? ''}`.toLowerCase().includes(searchFilter) + : true; + return matchesLibrary && matchesItemType && matchesRefreshState && matchesSearch; + }) + .sort((left, right) => { + return rank(left) - rank(right) + || left.library_id - right.library_id + || left.display_title.localeCompare(right.display_title) + || left.relative_path.localeCompare(right.relative_path); + }); +} + +export function metadataDashboardSummary(items: MediaItemSummary[]): { + failed: number; + pending: number; + stalled: number; + unmatched: number; +} { + return items.reduce((summary, item) => { + switch (metadataDashboardRefreshState(item)) { + case 'error': + summary.failed += 1; + break; + case 'pending': + summary.pending += 1; + break; + case 'stalled': + summary.stalled += 1; + break; + case 'unmatched': + summary.unmatched += 1; + break; + default: + break; + } + return summary; + }, { + failed: 0, + pending: 0, + stalled: 0, + unmatched: 0, + }); +} + +export function renderMetadataDashboard(): string { + const filteredItems = filteredMetadataDashboardItems(); + const summary = metadataDashboardSummary(state.dashboardItems); + const itemTypes = [...new Set(state.dashboardItems.map((item) => item.item_type))].sort((left, right) => left.localeCompare(right)); + let dashboardContent = '
No items matched the current dashboard filters.
'; + if (filteredItems.length) { + dashboardContent = ``; + } + + return ` + + `; +} + +export function renderSystemActivitiesPanel(): string { + const activities = state.systemActivities.filter((activity) => activity.state !== 'completed' && activity.state !== 'failed'); + let activitiesContent = '
No background activities are running right now.
'; + if (activities.length) { + activitiesContent = `
${activities.map((activity) => { + const progress = activityProgress(activity); + return ` +
+
+
+ ${escapeHtml(activity.label)} +

${escapeHtml(activity.scope)} · ${escapeHtml(activity.source)}

+
+
+ ${escapeHtml(activity.state)} + ${activity.provider_id ? `${escapeHtml(activity.provider_id)}` : ''} +
+
+
+ + ${progress.completed}/${progress.total}${progress.failed ? ` · ${progress.failed} failed` : ''} +
+
+ `; + }).join('')}
`; + } + + return ` +
+
+
+

Backend activities

+

Active background work that the browser is polling.

+
+ ${activities.length} active +
+ ${activitiesContent} +
+ `; +} + +export function renderLogViewer(): string { + const logEntries = state.logsResponse?.entries ?? []; + let logEntriesContent = '
No log entries matched the current filters.
'; + if (logEntries.length) { + logEntriesContent = `
+ + + + + + + + + + + ${logEntries.map((entry) => { + let levelTagClass = ''; + if (entry.level === 'ERROR') { + levelTagClass = 'danger-tag'; + } else if (entry.level === 'WARN') { + levelTagClass = 'warning'; + } + return ` + + + + + + + + `; + }).join('')} +
TimeLevelModuleSourceMessage
${escapeHtml(entry.timestamp)}${escapeHtml(entry.level)}${escapeHtml(entry.module)}${escapeHtml(entry.source_file_path)}${typeof entry.line_number === 'number' ? `:${entry.line_number}` : ''}
${escapeHtml(entry.message)}
+
`; + } + + return ` +
+
+
+

Logs

+

Structured logs from ${escapeHtml(state.logsResponse?.log_path ?? 'the current log file')}.

+
+ +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ ${logEntriesContent} +
+ `; +} diff --git a/crates/client-web/src/app/domPatcher.ts b/crates/client-web/src/app/domPatcher.ts new file mode 100644 index 00000000..f56375e4 --- /dev/null +++ b/crates/client-web/src/app/domPatcher.ts @@ -0,0 +1,290 @@ +/** Applies DOM patches while preserving focused controls and scroll state. */ +interface ScrollPosition { + top: number; + left: number; +} + +interface RenderSnapshot { + activeSelector?: string; + activeSelection?: { start: number; end: number }; + scrollPositions: Map; +} + +interface SetElementHtmlOptions { + preserveDom?: boolean; + beforePatch?: (root: ParentNode) => void; + abortEvents?: () => void; +} + +const DOM_PATCH_KEY_ATTRIBUTES = [ + 'id', + 'data-shelf-row', + 'data-lazy-shelf-id', + 'data-shelf-scroll', + 'data-item-id', + 'data-preview-item-id', + 'data-preview-collection-id', + 'data-person-id', + 'data-home-tab', + 'data-settings-section-path', + 'data-nav-library-id', + 'data-category-filter', + 'data-playlist-filter', + 'data-collection-filter', + 'data-link-metadata', + 'data-provider-settings', + 'data-provider-move', + 'data-run-scheduled-task', + 'data-refresh-library-id', + 'data-scan-library-id', + 'data-delete-missing-library-id', + 'data-remove-library-index', + 'data-update-user-id', + 'data-play-trailer-index', + 'data-play-extra-index', + 'data-play-selected-item-start-ms', + 'data-player-seek', + 'data-player-audio-track-index', + 'data-trailer-seek', +] as const; + +function elementSelectorForFocus(element: Element | null): string | undefined { + if (!element) { + return undefined; + } + if (element.id) { + return `#${CSS.escape(element.id)}`; + } + const namedControl = element instanceof HTMLInputElement + || element instanceof HTMLSelectElement + || element instanceof HTMLTextAreaElement + ? element + : undefined; + const name = namedControl?.name; + const formId = namedControl?.form?.id; + return name && formId + ? `#${CSS.escape(formId)} [name="${CSS.escape(name)}"]` + : undefined; +} + +function domPatchKey(node: Node): string | undefined { + if (!(node instanceof Element)) { + return undefined; + } + const tagName = node.tagName.toLowerCase(); + const keyAttribute = DOM_PATCH_KEY_ATTRIBUTES.find((attribute) => { + const value = node.getAttribute(attribute); + return value !== null && value !== ''; + }); + return keyAttribute ? `${tagName}:${keyAttribute}:${node.getAttribute(keyAttribute)}` : undefined; +} + +function scrollPositionKey(element: Element, index: number): string | undefined { + const patchKey = domPatchKey(element); + if (patchKey) { + return patchKey; + } + if (element.classList.contains('main-shell')) { + return 'main-shell'; + } + if (element.classList.contains('rail-nav')) { + return 'rail-nav'; + } + if (element.classList.contains('table-shell')) { + const rootId = element.closest('[id]')?.id ?? 'page'; + return `table-shell:${rootId}:${index}`; + } + return undefined; +} + +function captureRenderSnapshot(): RenderSnapshot { + const activeElement = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + const activeSelection = activeElement + && typeof activeElement.selectionStart === 'number' + && typeof activeElement.selectionEnd === 'number' + ? { start: activeElement.selectionStart, end: activeElement.selectionEnd } + : undefined; + const scrollPositions = new Map(); + document.querySelectorAll('.main-shell, .rail-nav, [data-shelf-row], .table-shell').forEach((element, index) => { + const key = scrollPositionKey(element, index); + if (key) { + scrollPositions.set(key, { top: element.scrollTop, left: element.scrollLeft }); + } + }); + + return { + activeSelector: elementSelectorForFocus(document.activeElement instanceof Element ? document.activeElement : null), + activeSelection, + scrollPositions, + }; +} + +function restoreRenderSnapshot(snapshot: RenderSnapshot): void { + globalThis.requestAnimationFrame(() => { + document.querySelectorAll('.main-shell, .rail-nav, [data-shelf-row], .table-shell').forEach((element, index) => { + const key = scrollPositionKey(element, index); + const position = key ? snapshot.scrollPositions.get(key) : undefined; + if (position) { + element.scrollTop = position.top; + element.scrollLeft = position.left; + } + }); + + const activeElement = snapshot.activeSelector + ? document.querySelector(snapshot.activeSelector) + : undefined; + activeElement?.focus({ preventScroll: true }); + if (snapshot.activeSelection && activeElement?.setSelectionRange) { + activeElement.setSelectionRange(snapshot.activeSelection.start, snapshot.activeSelection.end); + } + }); +} + +function nodesCanPatch(current: Node, next: Node): boolean { + if (current.nodeType !== next.nodeType) { + return false; + } + if (current instanceof Element && next instanceof Element) { + if (current.tagName !== next.tagName) { + return false; + } + const currentKey = domPatchKey(current); + const nextKey = domPatchKey(next); + if (currentKey || nextKey) { + return currentKey === nextKey; + } + if ( + current instanceof HTMLMediaElement + && next instanceof HTMLMediaElement + && current.getAttribute('src') !== next.getAttribute('src') + ) { + return false; + } + } + return true; +} + +function patchAttributes(current: Element, next: Element): void { + current.getAttributeNames().forEach((name) => { + if (!next.hasAttribute(name)) { + current.removeAttribute(name); + } + }); + next.getAttributeNames().forEach((name) => { + const value = next.getAttribute(name); + if (value !== null && current.getAttribute(name) !== value) { + current.setAttribute(name, value); + } + }); +} + +function syncFormControlState(current: Element, next: Element): void { + const isActive = current === document.activeElement; + if (current instanceof HTMLInputElement && next instanceof HTMLInputElement) { + current.defaultValue = next.defaultValue; + current.defaultChecked = next.defaultChecked; + if (!isActive) { + current.value = next.value; + current.checked = next.checked; + } + return; + } + + if (current instanceof HTMLTextAreaElement && next instanceof HTMLTextAreaElement) { + current.defaultValue = next.defaultValue; + if (!isActive) { + current.value = next.value; + } + return; + } + + if (current instanceof HTMLSelectElement && next instanceof HTMLSelectElement && !isActive) { + current.value = next.value; + } +} + +function patchNode(current: Node, next: Node): Node { + if (!nodesCanPatch(current, next)) { + current.parentNode?.replaceChild(next, current); + return next; + } + + if (current.nodeType === Node.TEXT_NODE) { + if (current.nodeValue !== next.nodeValue) { + current.nodeValue = next.nodeValue; + } + return current; + } + + if (current instanceof Element && next instanceof Element) { + patchAttributes(current, next); + patchChildren(current, next); + syncFormControlState(current, next); + } + + return current; +} + +function findKeyedPatchCandidate(parent: Node, startIndex: number, next: Node): Node | undefined { + const nextKey = domPatchKey(next); + if (!nextKey) { + return undefined; + } + return Array.from(parent.childNodes) + .slice(startIndex) + .find((candidate) => domPatchKey(candidate) === nextKey && nodesCanPatch(candidate, next)); +} + +function patchChildren(parent: Node, nextParent: ParentNode): void { + const nextChildren = Array.from(nextParent.childNodes); + let index = 0; + while (index < nextChildren.length || index < parent.childNodes.length) { + const currentChild = parent.childNodes[index]; + const nextChild = nextChildren[index]; + + if (!nextChild) { + currentChild?.remove(); + continue; + } + + if (!currentChild) { + parent.appendChild(nextChild); + index += 1; + continue; + } + + if (nodesCanPatch(currentChild, nextChild)) { + patchNode(currentChild, nextChild); + index += 1; + continue; + } + + const keyedCandidate = findKeyedPatchCandidate(parent, index + 1, nextChild); + if (keyedCandidate) { + currentChild.before(keyedCandidate); + patchNode(keyedCandidate, nextChild); + index += 1; + continue; + } + + currentChild.replaceWith(nextChild); + index += 1; + } +} + +/** Updates a root element with keyed DOM patching while preserving focus and scroll state. */ +export function setElementHtml(root: HTMLElement, html: string, options: SetElementHtmlOptions = {}): void { + const preserveDom = options.preserveDom ?? true; + if (!preserveDom || !root.childNodes.length) { + options.abortEvents?.(); + root.innerHTML = html; + return; + } + + const snapshot = captureRenderSnapshot(); + const template = document.createElement('template'); + template.innerHTML = html; + options.beforePatch?.(template.content); + patchChildren(root, template.content); + restoreRenderSnapshot(snapshot); +} diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts new file mode 100644 index 00000000..08751241 --- /dev/null +++ b/crates/client-web/src/app/eventBindings.ts @@ -0,0 +1,1395 @@ +/** Binds DOM events for each rendered app tree and owns transient UI timers. */ +import { createIcons, icons } from 'lucide'; +import { HOME_SHELF_CHUNK_SIZE } from './constants'; +import { currentLogFilterRequest, libraryHasActiveMetadataRefresh } from './activities'; +import { readProfileImageUpload } from './auth'; +import { renderLogViewer, renderMetadataDashboard } from './dashboardView'; +import { escapeHtml } from './format'; +import { formDataString, formDataStrings, normalizedMetadataLanguages, parseMetadataLanguageInput, parsePathsInput } from './formUtils'; +import { mediaExtraToTrailerOption } from './mediaExtras'; +import { currentThemeSongYouTubeTarget, currentTrailerOptions } from './mediaTargets'; +import { + bindPlayerProgress, + bindTrailerPlayer, + closeActivePlaybackSession, + closeTrailerPlayer, + openTrailer, + openVideoOverlay, + playYouTubeThemeSong, + startPlayback, + startPlaybackForItemId, +} from './playbackController'; +import { parseRoute } from './routes'; +import { + activeLibrary, + activeLibraryId, + backNavigationTarget, + categorySummaries, + collectionSummaries, + homePreviewCandidates, + pageBackdropUrlForCollection, + pageBackdropUrlForItem, + searchResultCollections, + showPreviewItemForHighlight, +} from './selectors'; +import { syncVisibleSpinners } from './spinners'; +import { state } from './state'; +import type { HomeBrowseTab } from './types'; +import { browseDetailPath, homeBrowsePath, renderHomeFeature, renderItemCard } from './homeView'; +import { + bindPersonCreditTrays, + defaultMetadataSearchLanguage, + selectedItemDefaultMetadataTitle, + selectedItemDefaultMetadataYear, + selectedItemExtras, +} from './itemPersonView'; +import { buildSettingsFromForm } from './settingsView'; +import { setButtonBusy } from './ui'; +import { + addLibrary, + clearMetadataCache, + clearStoredAuthToken, + createUser, + deleteLibrary, + deleteMissingItems, + getItemMetadata, + getLogs, + getUsers, + linkItemMetadata, + loginUser, + refreshItemMetadata, + refreshLibraryMetadata, + runScheduledTask, + scanLibrary, + searchItemMetadata, + setStoredAuthToken, + updateSettings, + updateUser, + type CreateUserRequest, + type LoginRequest, + type MediaLibrarySettings, + type MediaShelf, + type ScheduledTaskId, + type UpdateUserRequest, +} from '../api'; + +/** Replaces a DOM subtree while preserving any coordinator-level patch behavior. */ +export type ReplaceElementHtml = (root: HTMLElement, html: string, preserveDom?: boolean) => void; + +/** Coordinator callbacks required by render-scoped event handlers. */ +export interface AppEventBindingContext { + /** Navigates to an app route and optionally replaces the current history entry. */ + navigateTo: (path: string, replace?: boolean) => void; + /** Reloads bootstrap and route data. */ + refreshData: (showLoading?: boolean) => Promise; + /** Refreshes metadata-dependent route data after an item or library action. */ + refreshPendingMetadataData: () => Promise; + /** Re-renders the app shell. */ + render: (preserveScroll?: boolean) => void; + /** Schedules the background metadata refresh poller. */ + schedulePendingMetadataRefresh: (force?: boolean) => void; + /** Replaces a partial DOM subtree and reuses the app DOM patcher. */ + setElementHtml: ReplaceElementHtml; +} + +let activeBindingContext: AppEventBindingContext | undefined; + +function navigateTo(path: string, replace?: boolean): void { + if (!activeBindingContext) { + throw new Error('Event binding context has not been initialized.'); + } + activeBindingContext.navigateTo(path, replace); +} + +/** Aborts listeners bound to the previous rendered DOM tree. */ +export function abortRenderEvents(): void { + renderEventController?.abort(); +} + +/** Debounce handle for incremental home search updates. */ +let pendingLiveSearchHandle: number | undefined; + +function clearHomeSearch(): boolean { + const hadSearch = Boolean(state.searchQuery) || state.searchResults.length > 0 || state.showFullSearchResults; + if (pendingLiveSearchHandle !== undefined) { + globalThis.clearTimeout(pendingLiveSearchHandle); + pendingLiveSearchHandle = undefined; + } + state.searchQuery = ''; + state.searchResults = []; + state.showFullSearchResults = false; + return hadSearch; +} + +/** Tracks the one global resize listener used to refresh shelf controls. */ +let shelfScrollResizeBound = false; + +/** Controller for DOM event listeners attached during the latest render. */ +let renderEventController: AbortController | undefined; + + + +function setAuthFormBusy(form: HTMLFormElement, busy: boolean): void { + form.querySelectorAll('input, button').forEach((control) => { + control.disabled = busy; + }); +} + + + +async function refreshLogsView(context: AppEventBindingContext): Promise { + const { render, setElementHtml } = context; + if (state.route.page !== 'settings') { + return; + } + + try { + state.logsResponse = await getLogs(currentLogFilterRequest()); + state.error = undefined; + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to load logs.'; + } finally { + const root = document.querySelector('#log-viewer-panel-root'); + if (root) { + setElementHtml(root, renderLogViewer()); + createIcons({ icons }); + bindEvents(context); + } else { + render(); + } + } +} + + + +function updatePageBackdrop(backdropUrl: string | undefined): void { + const appShell = document.querySelector('.app-shell'); + const pageBackdrop = document.querySelector('.page-backdrop'); + if (backdropUrl) { + appShell?.classList.add('has-page-backdrop'); + if (pageBackdrop) { + const escapedBackdropUrl = backdropUrl.replace(/'/g, String.raw`\'`); + pageBackdrop.style.setProperty('--page-backdrop-image', `url('${escapedBackdropUrl}')`); + } else { + appShell?.insertAdjacentHTML('afterbegin', `
`); + } + } else { + appShell?.classList.remove('has-page-backdrop'); + pageBackdrop?.remove(); + } +} + + + +function bindHomeFeatureAction(): void { + document.querySelector('.home-feature [data-item-id]')?.addEventListener('click', () => { + const nextItemId = Number(document.querySelector('.home-feature [data-item-id]')?.dataset.itemId); + if (Number.isFinite(nextItemId)) { + navigateTo(`/items/${nextItemId}`); + } + }); + + document.querySelector('.home-feature [data-collection-filter]')?.addEventListener('click', () => { + const collectionId = document.querySelector('.home-feature [data-collection-filter]')?.dataset.collectionFilter; + if (collectionId) { + navigateTo(browseDetailPath('collection', collectionId)); + } + }); +} + + + +function refreshHomeFeatureElement(backdropUrl: string | undefined): void { + const root = document.querySelector('.home-feature'); + if (root) { + root.outerHTML = renderHomeFeature(); + createIcons({ icons }); + bindHomeFeatureAction(); + } + updatePageBackdrop(backdropUrl); +} + + + +function activatePreviewItem(itemId: number): void { + if (state.route.page === 'browse-detail' || !Number.isFinite(itemId) || state.homePreviewItemId === itemId) { + return; + } + state.homePreviewItemId = itemId; + state.homePreviewCollectionId = undefined; + const highlightedItem = homePreviewCandidates().find((item) => item.id === itemId); + const previewItem = highlightedItem ? showPreviewItemForHighlight(highlightedItem) : undefined; + refreshHomeFeatureElement(pageBackdropUrlForItem(previewItem)); +} + + + +function activatePreviewCollection(collectionId: string | undefined): void { + if (!collectionId || state.homePreviewCollectionId === collectionId) { + return; + } + state.homePreviewCollectionId = collectionId; + state.homePreviewItemId = undefined; + const collection = collectionSummaries().find((entry) => entry.id === collectionId) + ?? searchResultCollections().find((entry) => entry.id === collectionId); + refreshHomeFeatureElement(pageBackdropUrlForCollection(collection)); +} + + + +function bindItemNavigationElement(element: HTMLElement): void { + if (element.dataset.boundItemNavigation === 'true') { + return; + } + element.dataset.boundItemNavigation = 'true'; + element.addEventListener('click', () => { + const itemId = Number(element.dataset.itemId); + if (!Number.isFinite(itemId)) { + return; + } + + navigateTo(`/items/${itemId}`); + }); +} + + + +function bindPreviewItemElement(element: HTMLElement): void { + if (element.dataset.boundPreviewItem === 'true') { + return; + } + element.dataset.boundPreviewItem = 'true'; + const updatePreview = (): void => { + activatePreviewItem(Number(element.dataset.previewItemId)); + }; + element.addEventListener('mouseenter', updatePreview); + element.addEventListener('focus', updatePreview); +} + + + +function bindPreviewCollectionElement(element: HTMLElement): void { + if (element.dataset.boundPreviewCollection === 'true') { + return; + } + element.dataset.boundPreviewCollection = 'true'; + const updatePreview = (): void => { + activatePreviewCollection(element.dataset.previewCollectionId); + }; + element.addEventListener('mouseenter', updatePreview); + element.addEventListener('focus', updatePreview); +} + + + +function bindMediaCardInteractions(root: ParentNode): void { + root.querySelectorAll('[data-item-id]').forEach(bindItemNavigationElement); + root.querySelectorAll('[data-preview-item-id]').forEach(bindPreviewItemElement); + root.querySelectorAll('[data-preview-collection-id]').forEach(bindPreviewCollectionElement); +} + + + +function homeShelfForRow(row: HTMLElement): MediaShelf | undefined { + const shelfId = row.dataset.lazyShelfId; + return shelfId ? state.home?.shelves.find((shelf) => shelf.id === shelfId) : undefined; +} + + + +function appendLazyShelfItems(row: HTMLElement): boolean { + const shelf = homeShelfForRow(row); + if (!shelf) { + return false; + } + + const renderedCount = Number(row.dataset.lazyRenderedCount ?? row.children.length); + if (!Number.isFinite(renderedCount) || renderedCount >= shelf.items.length) { + row.dataset.lazyComplete = 'true'; + return false; + } + + const nextCount = Math.min(shelf.items.length, renderedCount + HOME_SHELF_CHUNK_SIZE); + row.insertAdjacentHTML('beforeend', shelf.items.slice(renderedCount, nextCount).map(renderItemCard).join('')); + row.dataset.lazyRenderedCount = String(nextCount); + row.dataset.lazyComplete = nextCount >= shelf.items.length ? 'true' : 'false'; + bindMediaCardInteractions(row); + createIcons({ icons }); + syncVisibleSpinners(); + updateShelfScrollControls(row); + return true; +} + + + +function appendLazyShelfItemsIfNeeded(row: HTMLElement): void { + const threshold = Math.max(360, row.clientWidth * 0.45); + while (row.dataset.lazyComplete !== 'true') { + const remainingScroll = row.scrollWidth - row.scrollLeft - row.clientWidth; + if (remainingScroll > threshold) { + return; + } + if (!appendLazyShelfItems(row)) { + return; + } + } +} + + + +function parseShelfScrollTarget(value: string | undefined): { shelfId: string; direction: number } | undefined { + const separatorIndex = value?.lastIndexOf(':') ?? -1; + if (!value || separatorIndex <= 0) { + return undefined; + } + + const shelfId = value.slice(0, separatorIndex); + const direction = Number(value.slice(separatorIndex + 1)); + return Number.isFinite(direction) ? { shelfId, direction } : undefined; +} + + + +function setShelfScrollButtonVisible(button: HTMLButtonElement | undefined, visible: boolean): void { + if (!button) { + return; + } + + button.classList.toggle('is-scroll-hidden', !visible); + button.disabled = !visible; + button.tabIndex = visible ? 0 : -1; + button.setAttribute('aria-hidden', visible ? 'false' : 'true'); +} + + + +function updateShelfScrollControls(row: HTMLElement): void { + const shelfId = row.dataset.shelfRow; + const shell = row.closest('.shelf-row-shell'); + if (!shelfId || !shell) { + return; + } + + const buttons = Array.from(shell.querySelectorAll('[data-shelf-scroll]')); + const leftButton = buttons.find((button) => parseShelfScrollTarget(button.dataset.shelfScroll)?.direction === -1); + const rightButton = buttons.find((button) => parseShelfScrollTarget(button.dataset.shelfScroll)?.direction === 1); + const hasOverflow = row.scrollWidth > row.clientWidth + 1; + const atLeftEdge = row.scrollLeft <= 1; + const atRightEdge = row.scrollLeft + row.clientWidth >= row.scrollWidth - 1; + + shell.classList.toggle('no-scroll', !hasOverflow); + setShelfScrollButtonVisible(leftButton, hasOverflow && !atLeftEdge); + setShelfScrollButtonVisible(rightButton, hasOverflow && !atRightEdge); +} + + + +function refreshShelfScrollControls(): void { + document.querySelectorAll('[data-shelf-row]').forEach(updateShelfScrollControls); +} + + + +function eventListenerOptionsWithSignal( + options: boolean | AddEventListenerOptions | undefined, + signal: AbortSignal, +): boolean | AddEventListenerOptions { + if (typeof options === 'boolean') { + return { capture: options, signal }; + } + + if (options === undefined) { + return { signal }; + } + + return { ...options, signal }; +} + +/** Binds all event handlers for the current rendered DOM tree. */ +export function bindEvents(context: AppEventBindingContext): void { + activeBindingContext = context; + renderEventController?.abort(); + renderEventController = new AbortController(); + const signal = renderEventController.signal; + const originalAddEventListener = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + if (this === globalThis) { + originalAddEventListener.call(this, type, listener, options); + return; + } + const optionsWithSignal = eventListenerOptionsWithSignal(options, signal); + originalAddEventListener.call(this, type, listener, optionsWithSignal); + }; + + try { + bindRenderEvents(context); + } finally { + EventTarget.prototype.addEventListener = originalAddEventListener; + } +} + + + +function bindRenderEvents(context: AppEventBindingContext): void { + const { navigateTo, refreshData, refreshPendingMetadataData, render, schedulePendingMetadataRefresh, setElementHtml } = context; + document.querySelector('#welcome-user-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + try { + const formData = new FormData(form); + const request: CreateUserRequest = { + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), + pin: formDataString(formData.get('pin')).trim() || undefined, + admin: true, + birthday: formDataString(formData.get('birthday')).trim() || undefined, + profile_image_upload: await readProfileImageUpload(formData), + preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), + }; + setAuthFormBusy(form, true); + await createUser(request); + const token = await loginUser({ username: request.username, password: request.password }); + setStoredAuthToken(token.token); + await refreshData(false); + } catch (error) { + setAuthFormBusy(form, false); + state.error = error instanceof Error ? error.message : 'Failed to create the first user.'; + render(); + } + }); + + document.querySelector('#login-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const request: LoginRequest = { + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), + }; + + try { + setAuthFormBusy(form, true); + const token = await loginUser(request); + setStoredAuthToken(token.token); + await refreshData(false); + } catch (error) { + setAuthFormBusy(form, false); + clearStoredAuthToken(); + state.error = error instanceof Error ? error.message : 'Failed to sign in.'; + render(); + } + }); + + document.querySelector('[data-sign-out]')?.addEventListener('click', () => { + clearStoredAuthToken(); + state.bootstrap = state.bootstrap ? { ...state.bootstrap, current_user: undefined } : undefined; + void refreshData(); + }); + + document.querySelector('[data-nav-home]')?.addEventListener('click', () => { + navigateTo('/'); + }); + + document.querySelectorAll('[data-nav-library-id]').forEach((button) => { + button.addEventListener('click', () => { + const libraryId = Number(button.dataset.navLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + navigateTo(`/libraries/${libraryId}`); + }); + }); + + document.querySelector('[data-nav-settings]')?.addEventListener('click', () => { + navigateTo('/settings'); + }); + + document.querySelectorAll('[data-settings-section-path]').forEach((button) => { + button.addEventListener('click', () => { + const path = button.dataset.settingsSectionPath; + if (path) { + navigateTo(path); + } + }); + }); + + document.querySelectorAll('[data-provider-settings]').forEach((button) => { + button.addEventListener('click', () => { + const providerId = button.dataset.providerSettings; + const providerHash = providerId ? `#provider-${providerId}` : ''; + navigateTo(`/settings/providers${providerHash}`); + }); + }); + + document.querySelectorAll('[data-provider-move]').forEach((button) => { + button.addEventListener('click', () => { + const option = button.closest('.metadata-provider-option'); + const list = option?.closest('.metadata-provider-list'); + if (!option || !list) { + return; + } + if (button.dataset.providerMove === 'up' && option.previousElementSibling) { + list.insertBefore(option, option.previousElementSibling); + } + if (button.dataset.providerMove === 'down' && option.nextElementSibling) { + list.insertBefore(option.nextElementSibling, option); + } + syncProviderDependencyOptions(list); + }); + }); + + document.querySelectorAll('.metadata-provider-list input[type="checkbox"]').forEach((input) => { + input.addEventListener('change', () => { + const list = input.closest('.metadata-provider-list'); + if (list) { + syncProviderDependencyOptions(list); + } + }); + }); + + document.querySelector('#search-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + const input = document.querySelector('#search-input'); + state.searchQuery = input?.value ?? ''; + state.showFullSearchResults = Boolean(state.searchQuery.trim()); + void refreshData(); + }); + + document.querySelector('#search-input')?.addEventListener('input', (event) => { + const input = event.currentTarget as HTMLInputElement; + state.searchQuery = input.value; + state.showFullSearchResults = false; + if (pendingLiveSearchHandle !== undefined) { + globalThis.clearTimeout(pendingLiveSearchHandle); + } + pendingLiveSearchHandle = globalThis.setTimeout(() => { + pendingLiveSearchHandle = undefined; + void refreshData(false); + }, 250); + }); + + document.querySelector('[data-clear-search]')?.addEventListener('click', () => { + clearHomeSearch(); + render(); + document.querySelector('#search-input')?.focus(); + }); + + document.querySelector('#refresh-active-library-metadata')?.addEventListener('click', async () => { + const button = document.querySelector('#refresh-active-library-metadata'); + const library = activeLibrary(); + if (!library || libraryHasActiveMetadataRefresh(library.id)) { + return; + } + + try { + setButtonBusy(button, true); + const refreshedLibrary = await refreshLibraryMetadata(library.id); + state.libraries = state.libraries.map((entry) => entry.id === refreshedLibrary.id ? refreshedLibrary : entry); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(true); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.'; + render(); + } + }); + + document.querySelectorAll('[data-home-tab]').forEach((button) => { + button.addEventListener('click', () => { + const nextTab = button.dataset.homeTab as HomeBrowseTab | undefined; + if (!nextTab) { + return; + } + + if (state.route.page === 'browse-detail') { + state.homeTab = nextTab; + state.browseFilter = undefined; + state.homePreviewItemId = undefined; + state.homePreviewCollectionId = undefined; + clearHomeSearch(); + const nextPath = homeBrowsePath(); + globalThis.history.pushState({}, '', nextPath); + state.route = parseRoute(); + void refreshData(); + return; + } + + const clearedSearch = clearHomeSearch(); + if (state.homeTab === nextTab) { + if (clearedSearch) { + render(); + } + return; + } + + state.homeTab = nextTab; + state.browseFilter = undefined; + state.homePreviewItemId = undefined; + state.homePreviewCollectionId = undefined; + render(); + }); + }); + + document.querySelectorAll('[data-category-filter]').forEach((button) => { + button.addEventListener('click', () => { + const genre = button.dataset.categoryFilter; + if (!genre) { + return; + } + + const category = categorySummaries().find((entry) => entry.genre === genre); + if (!category) { + return; + } + + navigateTo(browseDetailPath('category', category.genre)); + }); + }); + + document.querySelectorAll('[data-playlist-filter]').forEach((button) => { + button.addEventListener('click', () => { + const playlistName = button.dataset.playlistFilter; + if (!playlistName) { + return; + } + + navigateTo(browseDetailPath('playlist', playlistName)); + }); + }); + + document.querySelectorAll('[data-collection-filter]').forEach((button) => { + button.addEventListener('click', () => { + const collectionId = button.dataset.collectionFilter; + if (!collectionId) { + return; + } + + const collection = collectionSummaries().find((entry) => entry.id === collectionId); + const searchCollection = searchResultCollections().find((entry) => entry.id === collectionId); + if (!collection && !searchCollection) { + return; + } + + if (collection) { + navigateTo(browseDetailPath('collection', collection.id)); + } else { + navigateTo(`/items/collections/${encodeURIComponent(searchCollection!.id)}`); + } + }); + }); + + document.querySelector('#clear-browse-filter')?.addEventListener('click', () => { + state.browseFilter = undefined; + navigateTo(typeof activeLibraryId() === 'number' ? `/libraries/${activeLibraryId()}` : '/'); + }); + + document.querySelectorAll('[data-item-id]').forEach(bindItemNavigationElement); + + document.querySelectorAll('[data-person-id]').forEach((button) => { + button.addEventListener('click', () => { + const personId = Number(button.dataset.personId); + if (!Number.isFinite(personId)) { + return; + } + + navigateTo(`/people/${personId}`); + }); + }); + bindPersonCreditTrays(); + + document.querySelectorAll('[data-toggle-text]').forEach((button) => { + button.addEventListener('click', () => { + const key = button.dataset.toggleText; + if (!key) { + return; + } + if (state.expandedTextKeys.has(key)) { + state.expandedTextKeys.delete(key); + } else { + state.expandedTextKeys.add(key); + } + render(); + }); + }); + + document.querySelector('#back-to-library')?.addEventListener('click', () => { + if (state.route.page === 'person') { + globalThis.history.back(); + return; + } + + navigateTo(backNavigationTarget().path); + }); + + document.querySelector('#metadata-search-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!state.selectedItem) { + return; + } + + const input = document.querySelector('#metadata-search-input'); + const yearInput = document.querySelector('#metadata-search-year'); + const languageInput = document.querySelector('#metadata-search-language'); + state.metadataSearchQuery = input?.value.trim() || selectedItemDefaultMetadataTitle(); + state.metadataSearchYear = yearInput?.value.trim() || selectedItemDefaultMetadataYear(); + state.metadataSearchLanguage = languageInput?.value.trim() || defaultMetadataSearchLanguage(); + state.metadataSearchProviders = Array.from( + document.querySelectorAll('input[name="metadataSearchProvider"]:checked'), + ).map((provider) => provider.value); + try { + const submitButton = document.querySelector('#metadata-search-form button[type="submit"]'); + setButtonBusy(submitButton, true); + state.metadataSearchResults = await searchItemMetadata(state.selectedItem.id, { + query: state.metadataSearchQuery, + providers: state.metadataSearchProviders, + year: state.metadataSearchYear, + language: state.metadataSearchLanguage, + }); + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to search metadata.'; + render(); + } + }); + + document.querySelectorAll('[data-link-metadata]').forEach((button) => { + button.addEventListener('click', async () => { + const encoded = button.dataset.linkMetadata; + if (!encoded || !state.selectedItem) { + return; + } + + const [itemId, providerId, externalId, mediaType] = encoded.split(':'); + try { + await linkItemMetadata(Number(itemId), { + provider_id: providerId, + external_id: externalId, + media_type: mediaType, + }); + state.selectedItemMetadata = await getItemMetadata(state.selectedItem.id); + state.metadataSearchResults = []; + await refreshPendingMetadataData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to link metadata.'; + render(); + } + }); + }); + + document.querySelector('#refresh-item-metadata')?.addEventListener('click', async () => { + if (!state.selectedItem) { + return; + } + + try { + await refreshItemMetadata(state.selectedItem.id); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh item metadata.'; + render(); + } + }); + + const trailerButton = document.querySelector('#play-item-trailer'); + if (trailerButton) { + let trailerHoldHandle: number | undefined; + let suppressNextTrailerClick = false; + + const clearTrailerHoldHandle = (): void => { + if (trailerHoldHandle !== undefined) { + globalThis.clearTimeout(trailerHoldHandle); + trailerHoldHandle = undefined; + } + }; + + const openTrailerChooser = (): void => { + if (currentTrailerOptions().length <= 1) { + return; + } + + suppressNextTrailerClick = true; + state.isTrailerMenuOpen = true; + render(); + }; + + trailerButton.addEventListener('click', () => { + if (suppressNextTrailerClick) { + suppressNextTrailerClick = false; + return; + } + + openTrailer(currentTrailerOptions()[0]); + }); + trailerButton.addEventListener('contextmenu', (event) => { + if (currentTrailerOptions().length <= 1) { + return; + } + + event.preventDefault(); + clearTrailerHoldHandle(); + openTrailerChooser(); + }); + trailerButton.addEventListener('mousedown', () => { + clearTrailerHoldHandle(); + if (currentTrailerOptions().length <= 1) { + return; + } + + trailerHoldHandle = globalThis.setTimeout(() => { + trailerHoldHandle = undefined; + openTrailerChooser(); + }, 450); + }); + trailerButton.addEventListener('mouseup', clearTrailerHoldHandle); + trailerButton.addEventListener('mouseleave', clearTrailerHoldHandle); + trailerButton.addEventListener('touchstart', () => { + clearTrailerHoldHandle(); + if (currentTrailerOptions().length <= 1) { + return; + } + + trailerHoldHandle = globalThis.setTimeout(() => { + trailerHoldHandle = undefined; + openTrailerChooser(); + }, 500); + }, { passive: true }); + trailerButton.addEventListener('touchend', clearTrailerHoldHandle); + trailerButton.addEventListener('touchcancel', clearTrailerHoldHandle); + } + + document.querySelector('#close-trailer-picker')?.addEventListener('click', () => { + state.isTrailerMenuOpen = false; + render(); + }); + + document.querySelectorAll('[data-play-trailer-index]').forEach((button) => { + button.addEventListener('click', () => { + const trailerIndex = Number(button.dataset.playTrailerIndex); + if (!Number.isFinite(trailerIndex)) { + return; + } + + openTrailer(currentTrailerOptions()[trailerIndex]); + }); + }); + + document.querySelectorAll('[data-play-extra-index]').forEach((button) => { + button.addEventListener('click', () => { + const extraIndex = Number(button.dataset.playExtraIndex); + if (!Number.isFinite(extraIndex)) { + return; + } + + const extra = selectedItemExtras()[extraIndex]; + if (extra) { + openVideoOverlay(mediaExtraToTrailerOption(extra)); + } + }); + }); + + document.querySelector('#close-trailer')?.addEventListener('click', () => { + closeTrailerPlayer(); + }); + + document.querySelector('#play-youtube-theme-song')?.addEventListener('click', () => { + const target = currentThemeSongYouTubeTarget(); + if (target) { + playYouTubeThemeSong(target.videoId); + } + }); + + document.querySelectorAll('[data-play-selected-item-start-ms]').forEach((button) => button.addEventListener('click', async () => { + if (!state.selectedItem) { + return; + } + + try { + const startMs = Number(button.dataset.playSelectedItemStartMs); + await startPlayback(state.selectedItem, Number.isFinite(startMs) ? startMs : 0); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to start playback session.'; + state.isPlayerOpen = false; + state.activePlaybackItem = undefined; + render(); + } + })); + + document.querySelectorAll('[data-playback-target-item-id]').forEach((button) => button.addEventListener('click', async () => { + const itemId = Number(button.dataset.playbackTargetItemId); + const startMs = Number(button.dataset.playbackTargetStartMs); + if (!Number.isFinite(itemId)) { + return; + } + + try { + await startPlaybackForItemId(itemId, Number.isFinite(startMs) ? startMs : 0); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to start playback session.'; + state.isPlayerOpen = false; + state.activePlaybackItem = undefined; + render(); + } + })); + + document.querySelector('#close-player')?.addEventListener('click', () => { + closeActivePlaybackSession(); + }); + + document.querySelector('#settings-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const nextSettings = buildSettingsFromForm(new FormData(form)); + if (!nextSettings) { + return; + } + + try { + state.settingsResponse = await updateSettings(nextSettings); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to save settings.'; + render(); + } + }); + + document.querySelector('#go-home-from-settings')?.addEventListener('click', () => { + navigateTo('/'); + }); + + document.querySelector('#clear-metadata-cache')?.addEventListener('click', async () => { + const confirmed = globalThis.confirm('Clear cached provider metadata responses? The next metadata refresh will fetch fresh data from providers.'); + if (!confirmed) { + return; + } + try { + const button = document.querySelector('#clear-metadata-cache'); + setButtonBusy(button, true); + const response = await clearMetadataCache(); + state.error = `Cleared ${response.removed_files} metadata cache file${response.removed_files === 1 ? '' : 's'}.`; + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to clear metadata cache.'; + render(); + } + }); + + document.querySelectorAll('[data-run-scheduled-task]').forEach((button) => { + button.addEventListener('click', async () => { + const taskId = button.dataset.runScheduledTask as ScheduledTaskId | undefined; + if (!taskId) { + return; + } + + try { + setButtonBusy(button, true); + const response = await runScheduledTask(taskId); + state.error = response.message; + await refreshData(false); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to start scheduled task.'; + render(); + } + }); + }); + + document.querySelector('#metadata-dashboard-filter-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + state.metadataDashboardFilters = { + libraryId: formDataString(formData.get('dashboard_library_id')).trim(), + itemType: formDataString(formData.get('dashboard_item_type')).trim(), + refreshState: formDataString(formData.get('dashboard_refresh_state')).trim(), + search: formDataString(formData.get('dashboard_search')).trim(), + }; + const root = document.querySelector('#metadata-dashboard-panel-root'); + if (!root) { + render(); + return; + } + setElementHtml(root, renderMetadataDashboard()); + createIcons({ icons }); + bindEvents(context); + }); + + document.querySelector('#clear-metadata-dashboard-filters')?.addEventListener('click', () => { + state.metadataDashboardFilters = { + libraryId: '', + itemType: '', + refreshState: '', + search: '', + }; + const root = document.querySelector('#metadata-dashboard-panel-root'); + if (!root) { + render(); + return; + } + setElementHtml(root, renderMetadataDashboard()); + createIcons({ icons }); + bindEvents(context); + }); + + document.querySelector('#log-filter-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + state.logFilters = { + level: formDataString(formData.get('log_level')).trim().toUpperCase(), + module: formDataString(formData.get('log_module')).trim(), + search: formDataString(formData.get('log_search')).trim(), + since: formDataString(formData.get('log_since')).trim(), + until: formDataString(formData.get('log_until')).trim(), + }; + await refreshLogsView(context); + }); + + document.querySelector('#clear-log-filters')?.addEventListener('click', async () => { + state.logFilters = { + level: '', + module: '', + search: '', + since: '', + until: '', + }; + await refreshLogsView(context); + }); + + document.querySelector('#refresh-log-viewer')?.addEventListener('click', async () => { + await refreshLogsView(context); + }); + + document.querySelectorAll('[data-shelf-scroll]').forEach((button) => { + button.addEventListener('click', () => { + const target = parseShelfScrollTarget(button.dataset.shelfScroll); + const row = target + ? document.querySelector(`[data-shelf-row="${CSS.escape(target.shelfId)}"]`) + : undefined; + if (!row || !target) { + return; + } + row.scrollBy({ left: target.direction * Math.max(320, row.clientWidth * 0.8), behavior: 'smooth' }); + globalThis.setTimeout(() => { + appendLazyShelfItemsIfNeeded(row); + updateShelfScrollControls(row); + }, 220); + }); + }); + + document.querySelectorAll('[data-lazy-shelf-id]').forEach((row) => { + row.addEventListener('scroll', () => { + appendLazyShelfItemsIfNeeded(row); + updateShelfScrollControls(row); + }, { passive: true }); + }); + document.querySelectorAll('[data-shelf-row]:not([data-lazy-shelf-id])').forEach((row) => { + row.addEventListener('scroll', () => updateShelfScrollControls(row), { passive: true }); + }); + globalThis.requestAnimationFrame(refreshShelfScrollControls); + if (!shelfScrollResizeBound) { + globalThis.addEventListener('resize', refreshShelfScrollControls, { passive: true }); + shelfScrollResizeBound = true; + } + + document.querySelectorAll('[data-preview-item-id]').forEach(bindPreviewItemElement); + document.querySelectorAll('[data-preview-collection-id]').forEach(bindPreviewCollectionElement); + bindHomeFeatureAction(); + document.querySelector('#scan-active-library')?.addEventListener('click', async () => { + const button = document.querySelector('#scan-active-library'); + const library = activeLibrary(); + if (!library) { + return; + } + + try { + setButtonBusy(button, true); + const scannedLibrary = await scanLibrary(library.id); + state.libraries = state.libraries.map((entry) => entry.id === scannedLibrary.id ? scannedLibrary : entry); + await refreshData(false); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to scan library.'; + render(); + } + }); + + document.querySelectorAll('[data-update-user-id]').forEach((form) => { + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const userId = Number(form.dataset.updateUserId); + if (!Number.isFinite(userId)) { + return; + } + + try { + const formData = new FormData(form); + const profileImageUpload = await readProfileImageUpload(formData); + const removeProfileImage = formData.get('remove_profile_image') === 'on'; + const request: UpdateUserRequest = { + username: formDataString(formData.get('username')).trim(), + admin: formData.get('admin') === 'on', + birthday: formDataString(formData.get('birthday')).trim() || undefined, + profile_image_upload: profileImageUpload, + remove_profile_image: removeProfileImage, + preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), + }; + const updatedUser = await updateUser(userId, request); + state.users = state.users.map((user) => (user.id === updatedUser.id ? updatedUser : user)); + if (state.bootstrap?.current_user?.id === updatedUser.id) { + state.bootstrap.current_user = updatedUser; + } + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to update the user.'; + render(); + } + }); + }); + + document.querySelector('#create-user-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + try { + const formData = new FormData(form); + const request: CreateUserRequest = { + username: formDataString(formData.get('username')).trim(), + password: formDataString(formData.get('password')), + pin: formDataString(formData.get('pin')).trim() || undefined, + admin: formData.get('admin') === 'on', + birthday: formDataString(formData.get('birthday')).trim() || undefined, + profile_image_upload: await readProfileImageUpload(formData), + preferred_metadata_languages: parseMetadataLanguageInput(formData.get('preferred_metadata_languages')), + }; + await createUser(request); + form.reset(); + state.users = await getUsers(); + render(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to create the user.'; + render(); + } + }); + + document.querySelectorAll('[data-remove-library-index]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryIndex = Number(button.dataset.removeLibraryIndex); + if (!Number.isFinite(libraryIndex)) { + return; + } + + const confirmed = globalThis.confirm('Remove this library from settings? This only removes the configuration, not the media files on disk.'); + if (!confirmed) { + return; + } + + try { + state.settingsResponse = await deleteLibrary(libraryIndex); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to remove library.'; + render(); + } + }); + }); + + document.querySelectorAll('[data-refresh-library-id]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryId = Number(button.dataset.refreshLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + try { + const library = await refreshLibraryMetadata(libraryId); + state.libraries = state.libraries.map((entry) => entry.id === library.id ? library : entry); + await refreshPendingMetadataData(); + schedulePendingMetadataRefresh(true); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to refresh library metadata.'; + render(); + } + }); + }); + + document.querySelector('#add-library-form')?.addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement | null; + if (!form) { + return; + } + + const formData = new FormData(form); + const paths = parsePathsInput(formData.get('library_paths')); + const library: MediaLibrarySettings = { + name: formDataString(formData.get('library_name')), + path: paths[0] ?? '', + paths, + recursive: formData.get('library_recursive') === 'on', + kind: formDataString(formData.get('library_kind'), 'movies'), + scanner: formDataString(formData.get('library_scanner'), 'auto'), + metadata_providers: formDataStrings(formData.getAll('library_metadata_provider')), + metadata_language_mode: formDataString(formData.get('library_metadata_language_mode'), 'auto') === 'manual' ? 'manual' : 'auto', + metadata_languages: normalizedMetadataLanguages(formDataStrings(formData.getAll('library_metadata_language'))), + allowed_user_ids: formData.getAll('library_allowed_user') + .map(Number) + .filter((value) => Number.isFinite(value) && value > 0), + }; + + try { + state.settingsResponse = await addLibrary(library); + form.reset(); + await refreshData(); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to add library.'; + render(); + } + }); + document.querySelectorAll('[data-scan-library-id]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryId = Number(button.dataset.scanLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + try { + const scannedLibrary = await scanLibrary(libraryId); + state.libraries = state.libraries.map((entry) => entry.id === scannedLibrary.id ? scannedLibrary : entry); + await refreshData(false); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to scan library.'; + render(); + } + }); + }); + document.querySelectorAll('[data-delete-missing-library-id]').forEach((button) => { + button.addEventListener('click', async () => { + const libraryId = Number(button.dataset.deleteMissingLibraryId); + if (!Number.isFinite(libraryId)) { + return; + } + + const library = state.libraries.find((entry) => entry.id === libraryId); + const missingItems = library?.missing_items ?? 0; + const missingFiles = library?.missing_files ?? 0; + if (!globalThis.confirm(`Delete ${missingItems} missing item${missingItems === 1 ? '' : 's'} and ${missingFiles} missing file${missingFiles === 1 ? '' : 's'} from this library?`)) { + return; + } + + button.disabled = true; + try { + const cleanup = await deleteMissingItems(libraryId); + state.libraries = state.libraries.map((entry) => entry.id === cleanup.library.id ? cleanup.library : entry); + await refreshData(false); + } catch (error) { + state.error = error instanceof Error ? error.message : 'Failed to delete missing items.'; + render(); + } + }); + }); + + const addLibraryKindSelect = document.querySelector('#add-library-form select[name="library_kind"]'); + addLibraryKindSelect?.addEventListener('change', () => syncAddLibraryProviderOptions()); + syncAddLibraryProviderOptions(); + document.querySelectorAll('.metadata-provider-list').forEach(syncProviderDependencyOptions); + + bindPlayerProgress(); + bindTrailerPlayer(); +} + + + +function syncProviderDependencyOptions(list: HTMLElement): void { + const selectedPrimaryIds = new Set( + Array.from(list.querySelectorAll('.metadata-provider-option[data-provider-role="primary"] input[type="checkbox"]:checked')) + .map((input) => input.value), + ); + let priority = 0; + list.querySelectorAll('.metadata-provider-option').forEach((option) => { + const input = option.querySelector('input[type="checkbox"]'); + const label = option.querySelector('.provider-option-main .muted'); + const role = option.dataset.providerRole ?? 'primary'; + if (role === 'primary') { + priority += 1; + if (label) { + label.textContent = `Priority ${priority}`; + } + return; + } + + const extendsProviderIds = (option.dataset.extendsProviderIds ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const available = extendsProviderIds.some((providerId) => selectedPrimaryIds.has(providerId)); + if (input) { + input.disabled = !available; + if (!available) { + input.checked = false; + } + } + option.classList.toggle('is-disabled', !available); + if (label) { + label.textContent = available ? 'Secondary' : 'Requires primary provider'; + } + }); +} + + + +function syncAddLibraryProviderOptions(): void { + const form = document.querySelector('#add-library-form'); + const kind = form?.querySelector('select[name="library_kind"]')?.value; + if (!form || !kind) { + return; + } + + form.querySelectorAll('input[name="library_metadata_provider"]').forEach((input) => { + const supportedKinds = input.dataset.providerKinds?.split(',') ?? []; + const supported = supportedKinds.includes(kind); + const option = input.closest('.metadata-provider-option'); + input.disabled = !supported; + if (!supported) { + input.checked = false; + } + option?.classList.toggle('is-hidden', !supported); + }); + + const visibleCheckedProvider = form.querySelector( + 'input[name="library_metadata_provider"]:not(:disabled):checked', + ); + if (!visibleCheckedProvider) { + const firstVisibleProvider = form.querySelector('input[name="library_metadata_provider"]:not(:disabled)'); + if (firstVisibleProvider) { + firstVisibleProvider.checked = true; + } + } + form.querySelectorAll('.metadata-provider-list').forEach(syncProviderDependencyOptions); +} diff --git a/crates/client-web/src/app/formUtils.ts b/crates/client-web/src/app/formUtils.ts new file mode 100644 index 00000000..77011bbb --- /dev/null +++ b/crates/client-web/src/app/formUtils.ts @@ -0,0 +1,54 @@ +/** Parses a multiline path input into folder entries. */ +export function parsePathsInput(value: FormDataEntryValue | null | undefined): string[] { + return formDataString(value) + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** Returns a submitted form value only when it is text, never a File object. */ +export function formDataString(value: FormDataEntryValue | null | undefined, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +/** Returns only text values from a repeated form field. */ +export function formDataStrings(values: FormDataEntryValue[]): string[] { + return values.filter((value): value is string => typeof value === 'string'); +} + +/** Joins path entries for display in multiline textareas. */ +export function joinPaths(paths: string[]): string { + return paths.join('\n'); +} + +/** Parses a comma-separated metadata language field into unique locale codes. */ +export function parseMetadataLanguageInput(value: FormDataEntryValue | null): string[] { + const languages = formDataString(value) + .split(',') + .map((language) => language.trim()) + .filter(Boolean); + return languages.length ? languages : ['en-US']; +} + +/** Ensures metadata language lists always contain at least one locale. */ +export function normalizedMetadataLanguages(languages?: string[]): string[] { + const normalized = (languages ?? []) + .map((language) => language.trim()) + .filter(Boolean); + return normalized.length ? Array.from(new Set(normalized)) : ['en-US']; +} + +/** Parses and clamps an integer form value. */ +export function parseBoundedInteger( + value: FormDataEntryValue | null, + fallback: number, + min: number, + max: number, +): number { + const parsed = Number(formDataString(value, String(fallback))); + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.max(min, Math.min(max, Math.floor(parsed))); +} diff --git a/crates/client-web/src/app/format.ts b/crates/client-web/src/app/format.ts new file mode 100644 index 00000000..2fd31a54 --- /dev/null +++ b/crates/client-web/src/app/format.ts @@ -0,0 +1,79 @@ +/** Formats a Unix timestamp in seconds for display. */ +export function formatTimestamp(timestamp?: number): string { + if (!timestamp) { + return 'Unknown'; + } + + return new Date(timestamp * 1000).toLocaleString('en-US'); +} + +/** Formats a millisecond duration as H:MM:SS or M:SS. */ +export function formatDuration(durationMs?: number): string { + if (!durationMs) { + return 'Unknown'; + } + + const totalSeconds = Math.floor(durationMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** Formats a media playback position in seconds. */ +export function formatMediaTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0:00'; + } + + return formatDuration(Math.floor(seconds * 1000)); +} + +/** Formats a byte count using binary units. */ +export function formatFileSize(fileSize?: number): string { + if (!fileSize) { + return 'Unknown'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = fileSize; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +/** Formats a bitrate using bps, kbps, or Mbps. */ +export function formatBitRate(bitRate?: number): string { + if (!bitRate) { + return 'Unknown'; + } + + if (bitRate >= 1_000_000) { + return `${(bitRate / 1_000_000).toFixed(bitRate >= 10_000_000 ? 0 : 1)} Mbps`; + } + + if (bitRate >= 1_000) { + return `${Math.round(bitRate / 1_000)} kbps`; + } + + return `${bitRate} bps`; +} + +/** Escapes text that is interpolated into hand-built HTML strings. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts new file mode 100644 index 00000000..5c8c0831 --- /dev/null +++ b/crates/client-web/src/app/homeView.ts @@ -0,0 +1,1014 @@ +/** Renders home, browse, shelf, and media-card markup. */ +import type { MediaItemSummary, MediaLibrary, MediaPlaybackTarget, MediaSearchResult, MediaShelf } from '../api'; +import { getArtworkUrl, getPersonImageUrl, resolveApiUrl } from '../api'; +import { HOME_SHELF_CHUNK_SIZE } from './constants'; +import { escapeHtml, formatTimestamp } from './format'; +import { currentThemeSongYouTubeTarget } from './mediaTargets'; +import { playbackProgressPercent } from './playbackProgress'; +import { state } from './state'; +import type { BrowseFilter, HomeBrowseTab } from './types'; +import { + activeLibrary, + activeLibraryId, + categoryForRoute, + categorySummaries, + collectionForRoute, + collectionSummaries, + filteredTopLevelLibraryItems, + homeFeaturePreview, + itemsForCollection, + pageBackdropUrlForCollection, + pageBackdropUrlForItem, + topLevelLibraryItems, +} from './selectors'; +import { + activeLibraryPendingRefreshCount, + hasActiveLibraryScan, + itemIsMetadataPending, + libraryHasActiveMetadataRefresh, + metadataRefreshActivityProgressForLibrary, +} from './activities'; +import { + formatChildCount, + humanizeItemType, + libraryStatusLabel, + renderButtonContent, + renderCollapsibleText, + renderIcon, + selectedLibraryIcon, +} from './ui'; + +export function browseDetailPath(kind: BrowseFilter['kind'], key: string): string { + let segment = 'categories'; + if (kind === 'collection') { + segment = 'collections'; + } else if (kind === 'playlist') { + segment = 'playlists'; + } + const encodedKey = encodeURIComponent(key); + return typeof activeLibraryId() === 'number' + ? `/libraries/${activeLibraryId()}/items/${segment}/${encodedKey}` + : `/items/${segment}/${encodedKey}`; +} + +function browseFilterKindLabel(kind: BrowseFilter['kind']): string { + if (kind === 'collection') { + return 'Collection'; + } + if (kind === 'playlist') { + return 'Playlist'; + } + return 'Category'; +} + +export function homeBrowsePath(): string { + const libraryId = activeLibraryId(); + return typeof libraryId === 'number' ? `/libraries/${libraryId}` : '/'; +} + +export function browseFilterForRoute(): BrowseFilter | undefined { + if (state.route.page !== 'browse-detail') { + return undefined; + } + const route = state.route; + + if (route.kind === 'collection') { + const collection = collectionSummaries().find((entry) => entry.id === route.key); + if (!collection) { + return undefined; + } + + return { + kind: 'collection', + label: collection.name, + itemIds: collection.item_ids, + overview: collection.overview, + artworkUrl: collection.backdrop_url ?? collection.artwork_url, + }; + } + + if (route.kind === 'playlist') { + return { + kind: 'playlist', + label: route.key, + itemIds: [], + overview: 'No playlist items are available yet.', + }; + } + + const category = categorySummaries().find((entry) => entry.genre === route.key); + if (!category) { + return undefined; + } + + return { + kind: 'category', + label: category.genre, + itemIds: category.items.map((item) => item.id), + overview: category.items.slice(0, 5).map((item) => item.display_title).join(' · '), + }; +} + +export function renderBrowseFilterDetail(): string { + const filter = state.route.page === 'browse-detail' ? browseFilterForRoute() : state.browseFilter; + if (!filter) { + if (state.libraryItemsLoading) { + return '
Loading library items…
'; + } + return '
This page is no longer available for the current library.
'; + } + + const allowedIds = new Set(filter.itemIds); + const items = topLevelLibraryItems().filter((item) => allowedIds.has(item.id)); + const artworkStyle = filter.artworkUrl + ? `style="--home-feature-image: url('${escapeHtml(filter.artworkUrl)}');"` + : ''; + const themeSongOption = currentThemeSongYouTubeTarget(); + const filterKindLabel = browseFilterKindLabel(filter.kind); + const filterOverview = filter.overview ?? `${items.length} title${items.length === 1 ? '' : 's'} in this ${filter.kind}.`; + + return ` +
+
+
+

${escapeHtml(filterKindLabel)}

+

${escapeHtml(filter.label)}

+

${escapeHtml(filterOverview)}

+
+ ${items.length} title${items.length === 1 ? '' : 's'} +
+
+
+ ${themeSongOption ? `` : ''} + +
+
+
${items.map(renderItemCard).join('')}
+
+ `; +} + +export function renderCollectionDetailPage(): string { + const collection = collectionForRoute(); + if (!collection) { + if (state.libraryItemsLoading) { + return '
Loading collection…
'; + } + return '
This collection is no longer available for the current library.
'; + } + + const items = itemsForCollection(collection); + const themeSongOption = currentThemeSongYouTubeTarget(); + const posterUrl = collection.artwork_url ? resolveApiUrl(collection.artwork_url) : undefined; + const overview = collection.overview ?? 'No description is stored for this collection yet.'; + + return ` +
+
+
+ ${posterUrl ? `${escapeHtml(collection.name)} poster` : renderIcon('image', 'audio-player-art-icon')} +
+
+

Collection

+

${escapeHtml(collection.name)}

+
+ ${items.length} title${items.length === 1 ? '' : 's'} +
+ ${renderCollapsibleText(overview, `collection-overview:${collection.id}`)} +
+ ${themeSongOption ? `` : ''} + +
+
+
+ +
+
+

Items

+ ${items.length} item${items.length === 1 ? '' : 's'} +
+ ${items.length + ? `
${items.map(renderItemCard).join('')}
` + : '
No titles are currently linked to this collection.
'} +
+
+ `; +} + +export function renderCategoryDetailPage(): string { + const category = categoryForRoute(); + if (!category) { + if (state.libraryItemsLoading) { + return '
Loading genre…
'; + } + return '
This genre is no longer available for the current library.
'; + } + + const overview = category.items.slice(0, 5).map((item) => item.display_title).join(' · ') + || 'No titles are currently linked to this genre.'; + + return ` +
+
+
+ ${renderIcon('layout-grid', 'audio-player-art-icon')} +
+
+

Genre

+

${escapeHtml(category.genre)}

+
+ ${category.items.length} title${category.items.length === 1 ? '' : 's'} +
+ ${renderCollapsibleText(overview, `category-overview:${category.genre}`)} +
+ +
+
+
+ +
+
+

Items

+ ${category.items.length} item${category.items.length === 1 ? '' : 's'} +
+ ${category.items.length + ? `
${category.items.map(renderItemCard).join('')}
` + : '
No titles are currently linked to this genre.
'} +
+
+ `; +} + +export function renderPlaylistDetailPage(): string { + const route = state.route; + const playlistName = route.page === 'browse-detail' && route.kind === 'playlist' + ? route.key + : 'Playlist'; + + return ` +
+
+
+ ${renderIcon('play', 'audio-player-art-icon')} +
+
+

Playlist

+

${escapeHtml(playlistName)}

+
+ 0 titles +
+

No playlist items are available yet.

+
+ +
+
+
+ +
+
+

Items

+ 0 items +
+
Playlist creation is planned. Items will appear here when playlists are available.
+
+
+ `; +} + +export function renderBrowseDetailPage(): string { + if (state.route.page !== 'browse-detail') { + return renderBrowseFilterDetail(); + } + + switch (state.route.kind) { + case 'collection': + return renderCollectionDetailPage(); + case 'category': + return renderCategoryDetailPage(); + case 'playlist': + return renderPlaylistDetailPage(); + } + + return renderBrowseFilterDetail(); +} + +export function metadataBadgeMarkup(item: MediaItemSummary): string { + const pending = itemIsMetadataPending(item); + const unmatched = !item.has_metadata; + if (!pending && !unmatched) { + return ''; + } + + let statusLabel = 'Metadata is not linked yet'; + if (pending) { + statusLabel = unmatched ? 'Matching metadata' : 'Refreshing metadata'; + } + return ` + + ${unmatched ? `${renderIcon('triangle-alert', 'status-icon')}` : ''} + ${pending ? '' : ''} + + `; +} + +export function missingItemBadgeMarkup(item: MediaItemSummary): string { + if (!item.missing_since) { + return ''; + } + + const statusLabel = `Missing from disk since ${formatTimestamp(item.missing_since)}`; + return ` + + ${renderIcon('triangle-alert', 'status-icon')} + Missing + + `; +} + +export function missingItemDetailBadgeMarkup(item: MediaItemSummary): string { + if (!item.missing_since) { + return ''; + } + + const statusLabel = `Missing from disk since ${formatTimestamp(item.missing_since)}`; + return ` + + ${renderIcon('triangle-alert', 'status-icon')} + Missing + + `; +} + +export function playbackStatusBadgeMarkup(item: MediaItemSummary): string { + const badges: string[] = []; + const progressPercent = playbackProgressPercent(item); + if (progressPercent !== undefined) { + const label = `In progress: ${progressPercent}% watched`; + badges.push(` + + `); + } + + const watchCount = item.watch_count ?? 0; + if (watchCount > 0) { + const countLabel = watchCount === 1 ? 'Watched' : `Watched ${watchCount} times`; + badges.push(` + + ${renderIcon('circle-check', 'status-icon')} + + `); + } + + return badges.join(''); +} + +export function playbackDetailBadgeMarkup(item: MediaItemSummary): string { + const watchCount = item.watch_count ?? 0; + const progressPercent = playbackProgressPercent(item); + const badges: string[] = []; + if (watchCount > 0) { + const watchedLabel = watchCount === 1 ? 'Watched' : `Watched ${watchCount}x`; + const watchedTitle = item.last_watched_at ? `Last watched ${formatTimestamp(item.last_watched_at)}` : watchedLabel; + badges.push(`${renderIcon('circle-check', 'status-icon')}${escapeHtml(watchedLabel)}`); + } + if (progressPercent !== undefined) { + const progressLabel = `${progressPercent}% watched`; + badges.push(`${escapeHtml(progressLabel)}`); + } + + return badges.join(''); +} + +export function renderPlaybackTargetButton(target: MediaPlaybackTarget, secondary: boolean): string { + return ` + + `; +} + +export function itemCardSubtitle(item: MediaItemSummary): string | undefined { + if (item.display_subtitle) { + return item.display_subtitle; + } + + if (item.item_type === 'episode' && typeof item.episode_number === 'number') { + return `Episode ${item.episode_number}`; + } + + if (item.item_type === 'season' && typeof item.season_number === 'number') { + return `Season ${item.season_number}`; + } + + return undefined; +} + +function mediaCardBadgeGroup(markup: string, className: string): string { + if (!markup) { + return ''; + } + + return `${markup}`; +} + +function mediaCardDynamicBadges(badgeMarkup: string, playbackBadgeMarkup: string): string { + const badgeGroups = [ + mediaCardBadgeGroup(badgeMarkup, 'media-card-state-badges'), + mediaCardBadgeGroup(playbackBadgeMarkup, 'media-card-playback-badges'), + ].join(''); + if (!badgeGroups) { + return ''; + } + + return ` + + ${badgeGroups} + + `; +} + +export function renderItemCard(item: MediaItemSummary): string { + const library = state.libraries.find((entry) => entry.id === item.library_id); + const artworkItemId = item.artwork_item_id ?? item.id; + const artworkUrl = getArtworkUrl(artworkItemId, 'poster', item.artwork_updated_at); + const hasAlternateArtwork = typeof item.artwork_item_id === 'number' && item.artwork_item_id !== item.id; + const useEpisodeLayout = item.item_type === 'episode' && !hasAlternateArtwork; + const artworkTypeClass = useEpisodeLayout ? item.item_type : 'poster-art'; + const cardSubtitle = itemCardSubtitle(item); + const isSeasonEpisodeCard = state.route.page === 'item' + && state.selectedItem?.item_type === 'season' + && item.item_type === 'episode'; + let secondaryMeta: string | undefined; + if (!isSeasonEpisodeCard) { + secondaryMeta = state.route.page === 'home' && typeof state.route.libraryId === 'number' + ? humanizeItemType(item.item_type) + : `${library?.name ?? 'Library'} · ${humanizeItemType(item.item_type)}`; + } + const metricMarkup = item.missing_since + ? missingItemBadgeMarkup(item) + : `${escapeHtml(formatChildCount(item))}`; + const dynamicBadges = mediaCardDynamicBadges(metadataBadgeMarkup(item), playbackStatusBadgeMarkup(item)); + + return ` + + `; +} + +export function renderHomeFeature(): string { + const preview = homeFeaturePreview(); + if (!preview) { + return ''; + } + + if (preview.kind === 'collection') { + const collection = preview.collection; + const backdropUrl = pageBackdropUrlForCollection(collection); + return ` +
+
+

Collection

+

${escapeHtml(collection.name)}

+

${escapeHtml(collection.overview ?? `${collection.item_count} title${collection.item_count === 1 ? '' : 's'} in this collection.`)}

+
+ ${collection.item_count} title${collection.item_count === 1 ? '' : 's'} +
+
+ +
+ `; + } + + const item = preview.item; + const backdropUrl = pageBackdropUrlForItem(item); + const logoUrl = item.logo_url ? getArtworkUrl(item.id, 'logo', item.artwork_updated_at) : undefined; + const library = state.libraries.find((entry) => entry.id === item.library_id); + const genreMarkup = item.genres.slice(0, 3).map((genre) => `${escapeHtml(genre)}`).join(''); + + return ` +
+
+ ${logoUrl + ? `` + : `

${escapeHtml(item.display_title)}

`} +

${escapeHtml(item.overview ?? `${humanizeItemType(item.item_type)} from ${library?.name ?? 'your library'}.`)}

+
+ ${genreMarkup} + ${escapeHtml(formatChildCount(item))} +
+
+ +
+ `; +} + +type ItemSearchResult = Extract; +type CollectionSearchResult = Extract; +type PersonSearchResult = Extract; +type PlaylistSearchResult = Extract; + +function renderItemSearchResultRow(result: ItemSearchResult, compact: boolean): string { + const item = result.item; + const posterUrl = getArtworkUrl(item.id, 'poster', item.artwork_updated_at); + const library = state.libraries.find((entry) => entry.id === item.library_id); + const itemResultDetails = [library?.name ?? 'Library', humanizeItemType(item.item_type)]; + if (!compact) { + itemResultDetails.push(formatChildCount(item)); + } + + return ` + + `; +} + +function renderCollectionSearchResultRow(result: CollectionSearchResult, compact: boolean): string { + const collection = result.collection; + const posterUrl = collection.artwork_url ?? collection.backdrop_url; + return ` + + `; +} + +function renderPersonSearchResultRow(result: PersonSearchResult, compact: boolean): string { + const person = result.person; + const imageUrl = person.cached_image_path || person.image_url ? getPersonImageUrl(person.id) : undefined; + const knownFor = person.known_for.slice(0, 3).join(' · '); + return ` + + `; +} + +function renderPlaylistSearchResultRow(result: PlaylistSearchResult, compact: boolean): string { + const playlist = result.playlist; + return ` + + `; +} + +export function renderSearchResultRow(result: MediaSearchResult, compact: boolean): string { + switch (result.result_type) { + case 'item': + return renderItemSearchResultRow(result, compact); + case 'collection': + return renderCollectionSearchResultRow(result, compact); + case 'person': + return renderPersonSearchResultRow(result, compact); + case 'playlist': + return renderPlaylistSearchResultRow(result, compact); + } +} + +export function renderSearchResults(): string { + if (!state.searchResults.length) { + return '
No library content matched the current search.
'; + } + + return ` +
+
+

Search results

+ ${state.searchResults.length} matches +
+
+ ${state.searchResults.map((result) => renderSearchResultRow(result, false)).join('')} +
+
+ `; +} + +export function visibleShelfItems(shelf: MediaShelf): MediaItemSummary[] { + return shelf.items.slice(0, HOME_SHELF_CHUNK_SIZE); +} + +export function renderShelfStack(): string { + const shelves = (state.home?.shelves ?? []).filter((shelf) => shelf.items.length); + if (!shelves.length) { + return '
No shelves are available yet. Add a library to get started.
'; + } + + return shelves + .map((shelf) => ` +
+
+

${escapeHtml(shelf.title)}

+ ${shelf.items.length} items +
+
+ +
${visibleShelfItems(shelf).map(renderItemCard).join('')}
+ +
+
+ `) + .join(''); +} + +export function renderHomeTabs(): string { + const tabs: Array<{ id: HomeBrowseTab; label: string }> = [ + { id: 'recommended', label: 'Recommended' }, + { id: 'library', label: 'Library' }, + { id: 'collections', label: 'Collections' }, + { id: 'playlists', label: 'Playlists' }, + { id: 'categories', label: 'Categories' }, + ]; + + return ` + + `; +} + +type MetadataRefreshProgress = NonNullable>; + +function renderEmptyLibraryOverview(): string { + return ` +
+
+
+ Libraries + ${state.libraries.length} +
+
+ Items + ${topLevelLibraryItems().length} +
+
+ Status + ${state.libraries.some((entry) => entry.status === 'never_scanned') ? 'Pending scans' : 'Ready'} +
+
+
+ `; +} + +function renderLibraryRefreshStatusTag(library: MediaLibrary, activeRefreshProgress: MetadataRefreshProgress | undefined, stalePending: number): string { + if (activeRefreshProgress) { + return `Refreshing metadata ${activeRefreshProgress.completed}/${activeRefreshProgress.total}`; + } + + return stalePending > 0 + ? `Pending metadata ${library.metadata_refresh_completed}/${library.metadata_refresh_total}` + : ''; +} + +function libraryStatusTagClass(status: string): string { + if (status === 'available') { + return 'success'; + } + if (status === 'never_scanned') { + return 'warning'; + } + return ''; +} + +function renderMetadataRefreshNote(activeRefreshProgress: MetadataRefreshProgress | undefined): string { + const metadataRefreshFailedSuffix = activeRefreshProgress?.failed + ? ` (${activeRefreshProgress.failed} failed)` + : ''; + return activeRefreshProgress + ? `

Metadata refresh progress: ${activeRefreshProgress.completed}/${activeRefreshProgress.total}${metadataRefreshFailedSuffix}. Artwork and item cards update automatically as each item completes.

` + : ''; +} + +function renderStalePendingNote(stalePending: number): string { + const stalePendingVerb = stalePending === 1 ? ' is' : 's are'; + return stalePending > 0 + ? `

${stalePending} item${stalePendingVerb} still marked pending without an active refresh worker. Use refresh metadata to resume the library refresh.

` + : ''; +} + +export function renderLibraryOverview(): string { + const library = activeLibrary(); + if (!library) { + return renderEmptyLibraryOverview(); + } + + const activeRefreshProgress = metadataRefreshActivityProgressForLibrary(library.id); + const stalePending = Math.max(0, library.metadata_refresh_pending - activeLibraryPendingRefreshCount(library.id)); + const scanPending = hasActiveLibraryScan(library.id); + const refreshStatusTag = renderLibraryRefreshStatusTag(library, activeRefreshProgress, stalePending); + const metadataRefreshNote = renderMetadataRefreshNote(activeRefreshProgress); + const stalePendingNote = renderStalePendingNote(stalePending); + + return ` +
+
+
+

Library overview

+

${escapeHtml(library.name)}

+
+
+ ${scanPending ? 'Scanning catalog' : ''} + ${refreshStatusTag} +
+ ${escapeHtml(libraryStatusLabel(library.status))} + ${library.total_files} file${library.total_files === 1 ? '' : 's'} +
+
+
+
+
+ Top-level items + ${topLevelLibraryItems().length} +
+
+ Video files + ${library.video_files} +
+
+ Folders + ${library.paths.length} +
+
+ Last scanned + ${escapeHtml(formatTimestamp(library.last_scanned_at))} +
+
+ ${library.error ? `

${escapeHtml(library.error)}

` : ''} + ${library.status === 'never_scanned' ? '

This library has not been scanned yet. It will populate after the next catalog scan starts.

' : ''} + ${metadataRefreshNote} + ${stalePendingNote} +
+ `; +} + +export function renderLibraryTab(): string { + const items = filteredTopLevelLibraryItems(); + const library = activeLibrary(); + const isSpecificLibrary = state.route.page === 'home' && typeof state.route.libraryId === 'number'; + const browseFilterKind = state.browseFilter ? browseFilterKindLabel(state.browseFilter.kind) : ''; + + if (!items.length) { + if (state.libraryItemsLoading) { + return '
Loading library items…
'; + } + + if (state.browseFilter) { + return `
No items matched the current ${escapeHtml(state.browseFilter.kind)} filter.
`; + } + + if (library?.status === 'never_scanned') { + return '
This library has not been scanned yet. The show, season, and episode hierarchy will appear after the first scan completes.
'; + } + + if (library?.status && library.status !== 'available') { + return `
This library is not ready yet: ${escapeHtml(libraryStatusLabel(library.status))}.
`; + } + + return '
No browseable items are available yet for this library.
'; + } + + return ` +
+
+

${isSpecificLibrary ? 'All items' : 'All libraries'}

+ ${items.length} top-level item${items.length === 1 ? '' : 's'} +
+ ${state.browseFilter ? ` +
+ ${escapeHtml(browseFilterKind)} + ${escapeHtml(state.browseFilter.label)} + +
+ ` : ''} +
${items.map(renderItemCard).join('')}
+
+ `; +} + +export function renderCollectionsTab(): string { + const collections = collectionSummaries(); + if (!collections.length) { + return '
No linked collection data is available yet for this library.
'; + } + + return ` +
+ ${collections.map((collection) => { + const posterUrl = collection.artwork_url ?? collection.backdrop_url; + return ` + + `; + }).join('')} +
+ `; +} + +export function renderPlaylistsTab(): string { + return ` +
+ +
+ `; +} + +export function renderCategoriesTab(): string { + const categories = categorySummaries(); + if (!categories.length) { + return '
No genre metadata is available yet for the current library.
'; + } + + return ` +
+ ${categories.map((category) => ` + + `).join('')} +
+ `; +} + +export function renderHomeTabContent(): string { + if (state.route.page === 'browse-detail') { + return renderBrowseDetailPage(); + } + + if (state.browseFilter) { + return renderBrowseFilterDetail(); + } + + if (state.showFullSearchResults && state.searchQuery.trim()) { + return renderSearchResults(); + } + + switch (state.homeTab) { + case 'library': + return renderLibraryTab(); + case 'collections': + return renderCollectionsTab(); + case 'playlists': + return renderPlaylistsTab(); + case 'categories': + return renderCategoriesTab(); + default: + return renderShelfStack(); + } +} + +export function renderHomePage(): string { + if (state.route.page === 'browse-detail') { + return ` + ${renderHomeNavbar()} + ${renderBrowseDetailPage()} + `; + } + + return ` + ${renderHomeNavbar()} + ${renderHomeFeature()} +
${renderHomeTabContent()}
+ `; +} + +export function renderSearchPopover(): string { + if (!state.searchQuery.trim() || state.showFullSearchResults) { + return ''; + } + + if (!state.searchResults.length) { + return '
No library content matched the current search.
'; + } + + return ` +
+
+ Search results + ${state.searchResults.length} match${state.searchResults.length === 1 ? '' : 'es'} +
+
+ ${state.searchResults.slice(0, 8).map((result) => renderSearchResultRow(result, true)).join('')} +
+
+ `; +} + +export function renderHomeNavbar(): string { + const library = activeLibrary(); + const libraryRefreshPending = library ? libraryHasActiveMetadataRefresh(library.id) : false; + const libraryScanPending = library ? hasActiveLibraryScan(library.id) : hasActiveLibraryScan(); + const hasSearch = Boolean(state.searchQuery) || state.searchResults.length > 0 || state.showFullSearchResults; + const searchToggleLabel = hasSearch ? 'Clear search' : 'Search'; + const searchButtonType = hasSearch ? 'button' : 'submit'; + const searchClearAttribute = hasSearch ? 'data-clear-search' : ''; + const searchIcon = hasSearch ? 'x' : 'search'; + const scanButtonDisabled = libraryScanPending ? 'disabled' : ''; + const refreshButtonDisabled = libraryRefreshPending ? 'disabled' : ''; + const libraryActionButtons = library + ? ` + + + ` + : ''; + + return ` +
+ ${renderHomeTabs()} +
+
+ + +
+ ${libraryActionButtons} +
+ ${renderSearchPopover()} +
+ `; +} diff --git a/crates/client-web/src/app/input.ts b/crates/client-web/src/app/input.ts new file mode 100644 index 00000000..07963d4f --- /dev/null +++ b/crates/client-web/src/app/input.ts @@ -0,0 +1,80 @@ +import type { AppState } from './types'; + +const activeGamepadButtons = new Set(); + +function visibleFocusableElements(): HTMLElement[] { + return Array.from( + document.querySelectorAll( + 'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => element.offsetParent !== null); +} + +function moveFocus(direction: 1 | -1): void { + const focusable = visibleFocusableElements(); + if (!focusable.length) { + return; + } + const currentIndex = Math.max(0, focusable.indexOf(document.activeElement as HTMLElement)); + focusable[(currentIndex + direction + focusable.length) % focusable.length]?.focus(); +} + +function activateFocusedElement(): void { + const focused = document.activeElement as HTMLElement | null; + if (focused?.matches('button, a[href], input, select, textarea')) { + focused.click(); + } +} + +function pollGamepads(): void { + const gamepads = navigator.getGamepads?.() ?? []; + gamepads.forEach((gamepad) => { + if (!gamepad) { + return; + } + const actions: Array<[string, boolean, () => void]> = [ + ['up', Boolean(gamepad.buttons[12]?.pressed) || gamepad.axes[1] < -0.65, () => moveFocus(-1)], + ['down', Boolean(gamepad.buttons[13]?.pressed) || gamepad.axes[1] > 0.65, () => moveFocus(1)], + ['left', Boolean(gamepad.buttons[14]?.pressed) || gamepad.axes[0] < -0.65, () => moveFocus(-1)], + ['right', Boolean(gamepad.buttons[15]?.pressed) || gamepad.axes[0] > 0.65, () => moveFocus(1)], + ['activate', Boolean(gamepad.buttons[0]?.pressed), activateFocusedElement], + ['back', Boolean(gamepad.buttons[1]?.pressed), () => globalThis.history.back()], + ]; + actions.forEach(([name, pressed, action]) => { + const key = `${gamepad.index}:${name}`; + if (pressed && !activeGamepadButtons.has(key)) { + activeGamepadButtons.add(key); + action(); + } else if (!pressed) { + activeGamepadButtons.delete(key); + } + }); + }); + globalThis.requestAnimationFrame(pollGamepads); +} + +/** Binds global keyboard and gamepad navigation controls for non-player screens. */ +export function bindGlobalInputHandlers(state: Pick): void { + globalThis.addEventListener('keydown', (event) => { + if (state.isPlayerOpen || state.activeTrailer || event.defaultPrevented) { + return; + } + if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) { + return; + } + const target = event.target as HTMLElement | null; + if (target?.matches('input, textarea, select, [contenteditable="true"]')) { + return; + } + const focusable = visibleFocusableElements(); + if (!focusable.length) { + return; + } + const currentIndex = Math.max(0, focusable.indexOf(document.activeElement as HTMLElement)); + const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1; + focusable[(currentIndex + direction + focusable.length) % focusable.length]?.focus(); + event.preventDefault(); + }); + + globalThis.requestAnimationFrame(pollGamepads); +} diff --git a/crates/client-web/src/app/itemPersonView.ts b/crates/client-web/src/app/itemPersonView.ts new file mode 100644 index 00000000..53cb9c27 --- /dev/null +++ b/crates/client-web/src/app/itemPersonView.ts @@ -0,0 +1,1095 @@ +/** Renders item detail, metadata search, person detail, and credit trays. */ +import type { + ItemMetadataMatch, + ItemMetadataPerson, + MediaItemDetail, + MediaItemExtra, + MediaItemSummary, + MediaPlaybackTarget, + MetadataPersonItemCredit, + MetadataProviderStatus, +} from '../api'; +import { getArtworkUrl, getPersonImageUrl, resolveApiUrl } from '../api'; +import { escapeHtml, formatBitRate, formatDuration, formatFileSize, formatTimestamp } from './format'; +import { normalizedMetadataLanguages } from './formUtils'; +import { extractYouTubeVideoId } from './youtube'; +import { mediaExtraDurationLabel, mediaExtraTitle, mediaExtraTypeLabel } from './mediaExtras'; +import { currentThemeSongYouTubeTarget, currentTrailerOptions } from './mediaTargets'; +import { itemHasActiveMetadataRefresh, itemIsMetadataPending } from './activities'; +import { resumablePlaybackPositionMs } from './playbackProgress'; +import { providerAttributionLogo, providerDisplayName } from './providers'; +import { state } from './state'; +import type { PersonCreditGroup, TrailerOption } from './types'; +import { + activeLibrary, + activeLibrarySettings, + backNavigationTarget, + canManuallyLinkMetadata, + selectedItemCollectionRails, +} from './selectors'; +import { + renderButtonContent, + renderCollapsibleText, + renderIcon, +} from './ui'; +import { missingItemDetailBadgeMarkup, playbackDetailBadgeMarkup, renderItemCard, renderPlaybackTargetButton } from './homeView'; + +export function renderMetadataSearchResults(): string { + const selectedItem = state.selectedItem; + if (!selectedItem) { + return ''; + } + + if (!state.metadataSearchResults.length) { + return '
Search metadata providers to link rich metadata and artwork.
'; + } + + return state.metadataSearchResults + .map((result) => ` + + `) + .join(''); +} + +export function selectedItemMetadataProviderOptions(): MetadataProviderStatus[] { + const itemType = state.selectedItem?.item_type; + const libraryKind = activeLibrary()?.kind ?? libraryKindForItemType(itemType); + return (state.selectedItemMetadata?.providers ?? state.metadataProviders) + .filter((provider) => provider.role !== 'secondary') + .filter((provider) => provider.configured && provider.implemented) + .filter((provider) => !libraryKind || provider.supported_kinds.includes(libraryKind)); +} + +function libraryKindForItemType(itemType: string | undefined): string | undefined { + if (itemType === 'show') { + return 'shows'; + } + if (itemType === 'movie') { + return 'movies'; + } + return undefined; +} + +export function defaultMetadataSearchProviderIds(): string[] { + const providers = selectedItemMetadataProviderOptions(); + const providerIds = new Set(providers.map((provider) => provider.id)); + const libraryProviderIds = activeLibrary()?.metadata_providers + ?? activeLibrarySettings()?.metadata_providers; + const selectedLibraryProviders = (libraryProviderIds ?? []) + .filter((providerId) => providerIds.has(providerId)); + return libraryProviderIds ? selectedLibraryProviders : providers.map((provider) => provider.id); +} + +export function selectedItemDefaultMetadataTitle(): string { + return state.selectedItem?.display_title.trim() + || state.selectedItemMetadata?.matches[0]?.title?.trim() + || ''; +} + +export function selectedItemDefaultMetadataYear(): string { + const year = state.selectedItem?.release_year ?? state.selectedItemMetadata?.matches[0]?.release_year; + return typeof year === 'number' ? String(year) : ''; +} + +export function defaultMetadataSearchLanguage(): string { + const library = activeLibrary(); + const librarySettings = activeLibrarySettings(); + const metadataLanguageMode = library?.metadata_language_mode ?? librarySettings?.metadata_language_mode; + const metadataLanguages = library?.metadata_languages ?? librarySettings?.metadata_languages; + if (metadataLanguageMode === 'manual') { + return normalizedMetadataLanguages(metadataLanguages)[0] ?? 'en-US'; + } + return state.bootstrap?.current_user?.preferred_metadata_languages?.[0] + ?? state.metadataProviders.find((provider) => provider.configured)?.language + ?? 'en-US'; +} + +export function renderMetadataSearchProviderAttribution(providerId: string): string { + const label = providerDisplayName(providerId); + const logoUrl = providerAttributionLogo(providerId); + if (!logoUrl) { + return `${escapeHtml(label)}`; + } + return ``; +} + +export function renderMetadataSearchProviderControls(): string { + const providers = selectedItemMetadataProviderOptions(); + if (!providers.length) { + return ''; + } + + const selectedProviders = state.metadataSearchProviders.length + ? state.metadataSearchProviders + : defaultMetadataSearchProviderIds(); + + return ` + + `; +} + +export function renderLinkedMetadataSummary(): string { + const matches = state.selectedItemMetadata?.matches ?? []; + const linkedMatch = matches.find((match) => match.relation_kind === 'primary') ?? matches[0]; + if (!linkedMatch) { + return '
No external metadata is linked yet.
'; + } + + const metadataRefreshPending = itemIsMetadataPending(state.selectedItem); + const metadataRefreshActive = itemHasActiveMetadataRefresh(state.selectedItem); + let refreshStateLabel = 'Up to date'; + if (metadataRefreshActive) { + refreshStateLabel = 'Refreshing'; + } else if (metadataRefreshPending || linkedMatch.refresh_state === 'pending') { + refreshStateLabel = 'Pending without worker'; + } else if (linkedMatch.refresh_state === 'error') { + refreshStateLabel = 'Refresh failed'; + } + let refreshStateClass = ''; + if (metadataRefreshPending || linkedMatch.refresh_state === 'pending') { + refreshStateClass = 'warning'; + } else if (linkedMatch.refresh_state === 'error') { + refreshStateClass = 'danger-tag'; + } + const providersById = new Map( + (state.selectedItemMetadata?.providers ?? state.metadataProviders).map((provider) => [provider.id, provider]), + ); + const contributingProviderIds = [ + linkedMatch.provider_id, + ...matches.map((match) => match.provider_id).filter((providerId) => providerId !== linkedMatch.provider_id), + ].filter((providerId, index, providerIds) => providerIds.indexOf(providerId) === index); + const providerTags = contributingProviderIds + .map((providerId) => { + const className = providerId === linkedMatch.provider_id ? 'tag success' : 'tag'; + return `${escapeHtml(providerId)}`; + }) + .join(''); + const attributions = contributingProviderIds + .map((providerId) => providersById.get(providerId)) + .filter((provider): provider is MetadataProviderStatus => Boolean(provider?.attribution_text)) + .map((provider) => { + const logoUrl = providerAttributionLogo(provider.id); + const logoMarkup = logoUrl ? `` : ''; + return ``; + }) + .join(''); + + return ` + + `; +} + +export function selectedItemPeople(): ItemMetadataPerson[] { + return state.selectedItemMetadata?.matches[0]?.people ?? []; +} + +export function formatPersonDate(value?: string): string { + if (!value) { + return ''; + } + + const date = new Date(`${value}T00:00:00`); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export function personAgeLabel(birthday?: string, deathday?: string): string | undefined { + if (!birthday) { + return undefined; + } + const birthDate = new Date(`${birthday}T00:00:00`); + const endDate = deathday ? new Date(`${deathday}T00:00:00`) : new Date(); + if (Number.isNaN(birthDate.getTime()) || Number.isNaN(endDate.getTime())) { + return undefined; + } + let age = endDate.getFullYear() - birthDate.getFullYear(); + const birthdayThisYear = new Date(endDate.getFullYear(), birthDate.getMonth(), birthDate.getDate()); + if (endDate < birthdayThisYear) { + age -= 1; + } + return deathday ? `${age} at death` : `${age} years old`; +} + +export function renderPersonCredit(person: ItemMetadataPerson): string { + let imageUrl: string | undefined; + if (person.cached_image_path) { + imageUrl = getPersonImageUrl(person.person_id); + } else if (person.image_url) { + imageUrl = resolveApiUrl(person.image_url); + } + const subtitle = person.character_name || person.role || person.department || ''; + return ` + + `; +} + +export function renderPeopleRail(): string { + const people = selectedItemPeople(); + if (!people.length) { + return ''; + } + + return ` +
+
+

People

+ ${people.length} credit${people.length === 1 ? '' : 's'} +
+
+ +
+ ${people.map(renderPersonCredit).join('')} +
+ +
+
+ `; +} + +export function selectedItemExtras(): MediaItemExtra[] { + return (state.selectedItem?.extras ?? []).filter((extra) => Boolean(extra.url?.trim())); +} + +export function mediaExtraThumbnailUrl(extra: MediaItemExtra): string | undefined { + if (extra.thumbnail_url?.trim()) { + return resolveApiUrl(extra.thumbnail_url.trim()); + } + + const videoId = extractYouTubeVideoId(extra.url); + return videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : undefined; +} + +export function renderMediaExtraCard(extra: MediaItemExtra, index: number): string { + const title = mediaExtraTitle(extra); + const typeLabel = mediaExtraTypeLabel(extra.extra_type); + const durationLabel = mediaExtraDurationLabel(extra); + const thumbnailUrl = mediaExtraThumbnailUrl(extra); + const placeholderIcon = extra.extra_type === 'theme_song' ? 'music' : 'play'; + const thumbnailMarkup = thumbnailUrl + ? `${escapeHtml(title)} thumbnail` + : renderIcon(placeholderIcon, 'media-extra-placeholder-icon'); + return ` + + `; +} + +export function renderItemExtrasRail(): string { + const extras = selectedItemExtras(); + if (!extras.length) { + return ''; + } + + return ` +
+
+

Extras

+ ${extras.length} video${extras.length === 1 ? '' : 's'} +
+
+ +
+ ${extras.map((extra, index) => renderMediaExtraCard(extra, index)).join('')} +
+ +
+
+ `; +} + +export function renderSelectedItemCollectionRails(): string { + const rails = selectedItemCollectionRails(); + if (!rails.length) { + return ''; + } + + return rails + .map((rail, index) => { + const rowId = `item-collection-${index}`; + return ` +
+
+
+

${escapeHtml(rail.collection.name)}

+

Also in this collection

+
+ +
+
+ +
${rail.items.map(renderItemCard).join('')}
+ +
+
+ `; + }) + .join(''); +} + +export function itemSortKey(item: MediaItemSummary): string { + const season = typeof item.season_number === 'number' ? String(item.season_number).padStart(5, '0') : '99999'; + const episode = typeof item.episode_number === 'number' ? String(item.episode_number).padStart(5, '0') : '99999'; + return `${season}:${episode}:${item.display_title.toLocaleLowerCase()}`; +} + +export function compareMediaItems(left: MediaItemSummary, right: MediaItemSummary): number { + return itemSortKey(left).localeCompare(itemSortKey(right)); +} + +export function personCreditRootItem(entry: MetadataPersonItemCredit): MediaItemSummary { + return entry.hierarchy.find((item) => item.item_type === 'show') + ?? entry.hierarchy[0] + ?? entry.item; +} + +export function personCreditSeasonItem(entry: MetadataPersonItemCredit): MediaItemSummary | undefined { + if (entry.item.item_type === 'season') { + return entry.item; + } + + if (entry.item.item_type !== 'episode') { + return undefined; + } + + return [...entry.hierarchy].reverse().find((item) => item.item_type === 'season'); +} + +export function personCreditGroups(credits: MetadataPersonItemCredit[]): PersonCreditGroup[] { + const groupsByRootId = new Map(); + + credits.forEach((entry) => { + const root = personCreditRootItem(entry); + if (!groupsByRootId.has(root.id)) { + groupsByRootId.set(root.id, { root, seasons: [] }); + } + + const group = groupsByRootId.get(root.id)!; + const season = personCreditSeasonItem(entry); + if (!season) { + return; + } + + let seasonGroup = group.seasons.find((candidate) => candidate.season.id === season.id); + if (!seasonGroup) { + seasonGroup = { season, episodes: [] }; + group.seasons.push(seasonGroup); + } + + if (entry.item.item_type === 'episode' && !seasonGroup.episodes.some((episode) => episode.id === entry.item.id)) { + seasonGroup.episodes.push(entry.item); + } + }); + + const groups = [...groupsByRootId.values()] + .map((group) => ({ + ...group, + seasons: sortedPersonCreditSeasons(group), + })); + groups.sort((left, right) => left.root.display_title.localeCompare(right.root.display_title)); + return groups; +} + +function sortedPersonCreditSeasons(group: PersonCreditGroup): PersonCreditGroup['seasons'] { + const seasons = group.seasons.map((seasonGroup) => { + const episodes = [...seasonGroup.episodes]; + episodes.sort(compareMediaItems); + return { + ...seasonGroup, + episodes, + }; + }); + seasons.sort((left, right) => compareMediaItems(left.season, right.season)); + return seasons; +} + +function countLabel(count: number, singular: string): string { + if (count <= 0) { + return ''; + } + return `${count} ${singular}${count === 1 ? '' : 's'}`; +} + +export function renderPersonCreditGroup(group: PersonCreditGroup): string { + const seasonCount = group.seasons.length; + const episodeCount = group.seasons.reduce((total, season) => total + season.episodes.length, 0); + const traySummary = [ + countLabel(seasonCount, 'season'), + countLabel(episodeCount, 'episode'), + ].filter(Boolean).join(' · '); + const seasonTrayMarkup = group.seasons.length ? renderPersonSeasonCreditTray(group, traySummary) : ''; + + return ` +
+ ${renderItemCard(group.root)} +
+ ${seasonTrayMarkup} + `; +} + +function renderPersonSeasonCreditTray(group: PersonCreditGroup, traySummary: string): string { + return ` +
+
+ ${escapeHtml(traySummary || 'Credits')} + +
+
+ ${group.seasons.map((seasonGroup) => { + const episodeTrayMarkup = seasonGroup.episodes.length ? renderPersonEpisodeCreditTray(seasonGroup) : ''; + return ` +
+ ${renderItemCard(seasonGroup.season)} +
+ ${episodeTrayMarkup} + `; + }).join('')} +
+
+ `; +} + +function renderPersonEpisodeCreditTray(seasonGroup: PersonCreditGroup['seasons'][number]): string { + return ` +
+
+ ${escapeHtml(countLabel(seasonGroup.episodes.length, 'episode'))} + +
+
+ ${seasonGroup.episodes.map(renderItemCard).join('')} +
+
+ `; +} + +export function renderPersonPage(): string { + const response = state.selectedPerson; + if (!response) { + return '
Loading person details…
'; + } + + let personImageUrl: string | undefined; + if (response.person.cached_image_path) { + personImageUrl = getPersonImageUrl(response.person.id); + } else if (response.person.image_url) { + personImageUrl = resolveApiUrl(response.person.image_url); + } + const credits = response.credits; + const creditGroups = personCreditGroups(credits); + const age = personAgeLabel(response.person.birthday, response.person.deathday); + const knownForTags = response.person.known_for + .map((title) => `${escapeHtml(title)}`) + .join(''); + const knownForMarkup = response.person.known_for.length ? `
${knownForTags}
` : ''; + + return ` +
+
+
+ ${personImageUrl ? `${escapeHtml(response.person.name)}` : `${escapeHtml(response.person.name.slice(0, 1).toUpperCase())}`} +
+
+

${escapeHtml(response.person.name)}

+
+ ${escapeHtml(providerDisplayName(response.person.provider_id))} + ${credits.length} item${credits.length === 1 ? '' : 's'} + ${response.person.birthday ? `${escapeHtml([formatPersonDate(response.person.birthday), age].filter(Boolean).join(' · '))}` : ''} + ${response.person.gender ? `${escapeHtml(response.person.gender)}` : ''} +
+ ${response.person.birth_place ? `

${escapeHtml(response.person.birth_place)}

` : ''} + ${response.person.biography ? renderCollapsibleText(response.person.biography, `person-biography:${response.person.id}`) : ''} + ${knownForMarkup} +
+ + ${response.person.profile_url ? `Provider page` : ''} +
+
+
+ +
+
+

Credits

+ ${creditGroups.length} title${creditGroups.length === 1 ? '' : 's'} +
+ ${credits.length + ? `
${creditGroups.map(renderPersonCreditGroup).join('')}
` + : '
No linked items are stored for this person yet.
'} +
+
+ `; +} + +export function directGridChildren(grid: HTMLElement, selector: string): HTMLElement[] { + return Array.from(grid.children) + .filter((child): child is HTMLElement => child instanceof HTMLElement && child.matches(selector)); +} + +export function directGridChildByData(grid: HTMLElement, selector: string, key: string, value: string | undefined): HTMLElement | undefined { + if (!value) { + return undefined; + } + + return directGridChildren(grid, selector).find((child) => child.dataset[key] === value); +} + +export function rowIndexForElement(element: HTMLElement, rowTops: number[]): number { + const rowIndex = rowTops.findIndex((top) => Math.abs(top - element.offsetTop) < 8); + return Math.max(rowIndex, 0); +} + +export function activatePersonCreditTray( + grid: HTMLElement, + card: HTMLElement, + tray: HTMLElement, + cardSelector: string, + traySelector: string, +): void { + if (card.classList.contains('is-active') && tray.classList.contains('is-active')) { + return; + } + + const cards = directGridChildren(grid, cardSelector); + const trays = directGridChildren(grid, traySelector); + + trays.forEach((entry) => { + entry.classList.remove('is-active'); + entry.style.removeProperty('order'); + }); + cards.forEach((entry) => { + entry.classList.remove('is-active'); + entry.style.removeProperty('order'); + }); + + const rowTops = [...new Set(cards + .map((entry) => entry.offsetTop) + .sort((left, right) => left - right) + .filter((top, index, values) => index === 0 || Math.abs(top - values[index - 1]) >= 8))]; + + cards.forEach((entry) => { + entry.style.order = String(rowIndexForElement(entry, rowTops) * 2); + }); + + card.classList.add('is-active'); + tray.classList.add('is-active'); + tray.style.order = String(rowIndexForElement(card, rowTops) * 2 + 1); +} + +export function collapsePersonCreditTrays(grid: HTMLElement, cardSelector: string, traySelector: string): void { + directGridChildren(grid, cardSelector).forEach((entry) => { + entry.classList.remove('is-active'); + entry.style.removeProperty('order'); + }); + directGridChildren(grid, traySelector).forEach((entry) => { + entry.classList.remove('is-active'); + entry.style.removeProperty('order'); + }); +} + +export function bindPersonCreditTrays(): void { + const grid = document.querySelector('.person-credit-grid'); + if (!grid) { + return; + } + + const activateRootTray = (target: EventTarget | null): void => { + const card = target instanceof Element ? target.closest('.person-credit-card') : null; + if (card?.parentElement !== grid) { + return; + } + + const tray = directGridChildByData(grid, '.person-season-tray', 'personCreditId', card.dataset.personCreditId); + if (!tray) { + return; + } + + activatePersonCreditTray(grid, card, tray, '.person-credit-card', '.person-season-tray'); + }; + + const activateSeasonTray = (target: EventTarget | null): void => { + const card = target instanceof Element ? target.closest('.person-season-credit-card') : null; + const seasonGrid = card?.parentElement; + if (!card || !(seasonGrid instanceof HTMLElement) || !seasonGrid.classList.contains('person-season-credit-grid')) { + return; + } + + const tray = directGridChildByData(seasonGrid, '.person-episode-tray', 'personSeasonCreditId', card.dataset.personSeasonCreditId); + if (!tray) { + return; + } + + activatePersonCreditTray(seasonGrid, card, tray, '.person-season-credit-card', '.person-episode-tray'); + }; + + grid.addEventListener('mouseover', (event) => { + activateRootTray(event.target); + activateSeasonTray(event.target); + }); + grid.addEventListener('focusin', (event) => { + activateRootTray(event.target); + activateSeasonTray(event.target); + }); + grid.addEventListener('click', (event) => { + const target = event.target instanceof Element ? event.target : null; + const rootCloseButton = target?.closest('[data-close-person-credit-tray]'); + if (rootCloseButton) { + event.preventDefault(); + event.stopPropagation(); + collapsePersonCreditTrays(grid, '.person-credit-card', '.person-season-tray'); + return; + } + + const seasonCloseButton = target?.closest('[data-close-person-season-credit-tray]'); + const seasonGrid = seasonCloseButton?.closest('.person-season-credit-grid'); + if (seasonCloseButton && seasonGrid) { + event.preventDefault(); + event.stopPropagation(); + collapsePersonCreditTrays(seasonGrid, '.person-season-credit-card', '.person-episode-tray'); + } + }); +} + +function selectedItemPosterUrl(item: MediaItemDetail): string | undefined { + return item.poster_url + ? getArtworkUrl(item.id, 'poster', item.artwork_updated_at) + : undefined; +} + +function selectedItemLogoUrl(item: MediaItemDetail): string | undefined { + return item.logo_url ? resolveApiUrl(item.logo_url) : undefined; +} + +function selectedItemOverview(item: MediaItemDetail): string { + return item.overview + ?? state.selectedItemMetadata?.matches[0]?.overview + ?? 'No description is stored for this item yet.'; +} + +function selectedItemChildSectionTitle(item: MediaItemDetail): string { + if (item.item_type === 'show') { + return 'Seasons'; + } + + return item.item_type === 'season' ? 'Episodes' : 'Contained items'; +} + +function renderSelectedItemBreadcrumbs(item: MediaItemDetail): string { + if (!item.hierarchy.length) { + return ''; + } + + return ` + + `; +} + +function renderSelectedItemPoster(item: MediaItemDetail, posterUrl: string | undefined): string { + return posterUrl + ? `${escapeHtml(item.display_title)} poster` + : `${escapeHtml(item.display_title.slice(0, 1).toUpperCase())}`; +} + +function renderSelectedItemTitle(item: MediaItemDetail, logoUrl: string | undefined): string { + return logoUrl + ? `` + : `

${escapeHtml(item.display_title)}

`; +} + +function renderSelectedItemHeroMeta(item: MediaItemDetail, genres: string[]): string { + const tags = [ + missingItemDetailBadgeMarkup(item), + playbackDetailBadgeMarkup(item), + ]; + if (item.release_year) { + tags.push(`${item.release_year}`); + } + if (item.content_rating) { + tags.push(`${escapeHtml(item.content_rating)}`); + } + if (typeof item.rating === 'number') { + tags.push(`${escapeHtml(item.rating.toFixed(1))}`); + } + tags.push(...genres.map((genre) => `${escapeHtml(genre)}`)); + + return `
${tags.join('')}
`; +} + +function renderResumeButton(item: MediaItemDetail, resumeMs: number): string { + if (!item.playable || resumeMs <= 0) { + return ''; + } + + const resumeLabel = `Resume ${formatDuration(resumeMs)}`; + return ``; +} + +function renderPrimaryPlayButton(item: MediaItemDetail, resumeMs: number): string { + if (!item.playable) { + return ''; + } + + const playButtonClass = resumeMs > 0 ? 'secondary-button' : ''; + const playButtonLabel = resumeMs > 0 ? 'Start over' : 'Play now'; + return ``; +} + +function renderTrailerActionButton(preferredTrailer: TrailerOption | undefined, trailerButtonTitle: string): string { + return preferredTrailer + ? `` + : ''; +} + +function renderThemeSongButton(themeSongOption: ReturnType): string { + return themeSongOption + ? `` + : ''; +} + +interface SelectedItemActionsOptions { + item: MediaItemDetail; + resumeMs: number; + playbackTarget?: MediaPlaybackTarget; + restartPlaybackTarget?: MediaPlaybackTarget; + preferredTrailer?: TrailerOption; + themeSongOption?: ReturnType; + trailerButtonTitle: string; + backTarget: ReturnType; +} + +function renderSelectedItemActions(options: SelectedItemActionsOptions): string { + const { + item, + resumeMs, + playbackTarget, + restartPlaybackTarget, + preferredTrailer, + themeSongOption, + trailerButtonTitle, + backTarget, + } = options; + + return ` +
+ ${renderResumeButton(item, resumeMs)} + ${renderPrimaryPlayButton(item, resumeMs)} + ${playbackTarget ? renderPlaybackTargetButton(playbackTarget, false) : ''} + ${restartPlaybackTarget ? renderPlaybackTargetButton(restartPlaybackTarget, true) : ''} + ${renderTrailerActionButton(preferredTrailer, trailerButtonTitle)} + ${renderThemeSongButton(themeSongOption)} + +
+ `; +} + +function playableTarget(item: MediaItemDetail): MediaPlaybackTarget | undefined { + return item.playable ? undefined : item.playback_target ?? undefined; +} + +function restartPlayableTarget(item: MediaItemDetail): MediaPlaybackTarget | undefined { + return item.playable ? undefined : item.restart_playback_target ?? undefined; +} + +function renderTrailerPicker(trailerOptions: TrailerOption[], hasMultipleTrailers: boolean): string { + if (!hasMultipleTrailers || !state.isTrailerMenuOpen) { + return ''; + } + + return ` +
+
+

Choose a trailer

+ +
+
+ ${trailerOptions.map((option, index) => ` + + `).join('')} +
+
+ `; +} + +function selectedItemTechnicalFacts(item: MediaItemDetail): Array<{ label: string; value: string }> { + return [ + { label: 'Duration', value: formatDuration(item.duration_ms) }, + { + label: 'Format', + value: [item.container?.toUpperCase(), item.media_kind.toUpperCase()].filter(Boolean).join(' • ') || 'Unknown', + }, + { + label: 'Codecs', + value: [item.video_codec, item.audio_codec].filter(Boolean).join(' / ') || 'Unknown', + }, + { + label: 'Resolution', + value: item.width && item.height ? `${item.width}×${item.height}` : 'Unknown', + }, + { label: 'Bitrate', value: formatBitRate(item.bit_rate) }, + { label: 'Size', value: formatFileSize(item.file_size) }, + ]; +} + +function renderSelectedItemFactList(item: MediaItemDetail): string { + return ` +
+ ${selectedItemTechnicalFacts(item).map((fact) => ` +
+ ${escapeHtml(fact.label)} + ${escapeHtml(fact.value)} +
+ `).join('')} +
+ `; +} + +function renderSelectedItemHero( + item: MediaItemDetail, + posterUrl: string | undefined, + logoUrl: string | undefined, + overview: string, + genres: string[], + actionsMarkup: string, + trailerPickerMarkup: string, +): string { + const itemHeroClass = item.item_type === 'episode' ? 'episode-hero' : ''; + const itemPosterClass = item.item_type === 'episode' ? 'item-thumbnail' : ''; + return ` +
+
+ ${renderSelectedItemPoster(item, posterUrl)} +
+
+ ${renderSelectedItemTitle(item, logoUrl)} + ${item.tagline ? `

${escapeHtml(item.tagline)}

` : ''} + ${renderSelectedItemHeroMeta(item, genres)} + ${renderCollapsibleText(overview, `item-overview:${item.id}`)} + ${actionsMarkup} + ${trailerPickerMarkup} +

${escapeHtml(state.selectedPlayback?.reason ?? 'Loading playback capabilities…')}

+ ${renderSelectedItemFactList(item)} +
+
+ `; +} + +function renderSelectedItemChildrenSection(item: MediaItemDetail): string { + if (!item.children.length) { + return ''; + } + + const childCountLabel = countLabel(item.children.length, 'item'); + const childGridClass = item.item_type === 'season' ? 'season-episodes-grid' : ''; + return ` +
+
+

${escapeHtml(selectedItemChildSectionTitle(item))}

+ ${childCountLabel} +
+
${item.children.map(renderItemCard).join('')}
+
+ `; +} + +function renderMetadataRefreshButton( + supportsManualLinking: boolean, + linkedMatch: ItemMetadataMatch | undefined, + metadataRefreshActive: boolean, +): string { + if (!supportsManualLinking) { + return ''; + } + + const refreshButtonDisabled = linkedMatch && !metadataRefreshActive ? '' : 'disabled'; + const refreshButtonLabel = metadataRefreshActive ? 'Refreshing metadata' : 'Force refresh metadata'; + return ``; +} + +function renderMetadataSearchPanel(supportsManualLinking: boolean): string { + if (!supportsManualLinking) { + return '
Season and episode metadata is inherited and refreshed automatically from the linked show.
'; + } + + return ` + + + `; +} + +function renderSelectedItemSupportGrid( + item: MediaItemDetail, + library: ReturnType, + supportsManualLinking: boolean, + metadataRefreshButtonMarkup: string, + metadataSearchPanel: string, +): string { + return ` +
+
+
+

File and library

+
+
+
+ Library + ${escapeHtml(library?.name ?? 'Unknown')} +
+
+ Folders + ${escapeHtml(String(library?.paths.length ?? 0))} +
+
+ Source + ${escapeHtml(item.relative_path)} +
+
+ Updated + ${escapeHtml(formatTimestamp(item.modified_at))} +
+
+
+ + +
+ `; +} + +function renderSelectedItemPage(item: MediaItemDetail): string { + const trailerOptions = currentTrailerOptions(); + const hasMultipleTrailers = trailerOptions.length > 1; + const supportsManualLinking = canManuallyLinkMetadata(item); + const linkedMatch = state.selectedItemMetadata?.matches[0]; + const metadataRefreshActive = itemHasActiveMetadataRefresh(item); + const resumeMs = resumablePlaybackPositionMs(item); + const trailerButtonTitle = hasMultipleTrailers + ? 'Click to play the first trailer. Right-click or press and hold to choose another trailer.' + : 'Play Trailer'; + const actionsMarkup = renderSelectedItemActions({ + item, + resumeMs, + playbackTarget: playableTarget(item), + restartPlaybackTarget: restartPlayableTarget(item), + preferredTrailer: trailerOptions[0], + themeSongOption: currentThemeSongYouTubeTarget(), + trailerButtonTitle, + backTarget: backNavigationTarget(), + }); + const metadataRefreshButtonMarkup = renderMetadataRefreshButton(supportsManualLinking, linkedMatch, metadataRefreshActive); + + return ` +
+ ${renderSelectedItemBreadcrumbs(item)} + ${renderSelectedItemHero( + item, + selectedItemPosterUrl(item), + selectedItemLogoUrl(item), + selectedItemOverview(item), + item.genres.length ? item.genres : [], + actionsMarkup, + renderTrailerPicker(trailerOptions, hasMultipleTrailers), + )} + + ${renderPeopleRail()} + + ${renderItemExtrasRail()} + + ${renderSelectedItemChildrenSection(item)} + + ${renderSelectedItemCollectionRails()} + + ${renderSelectedItemSupportGrid( + item, + state.libraries.find((entry) => entry.id === item.library_id), + supportsManualLinking, + metadataRefreshButtonMarkup, + renderMetadataSearchPanel(supportsManualLinking), + )} +
+ `; +} + +export function renderItemPage(): string { + if (!state.selectedItem) { + return '
Loading item details…
'; + } + + return renderSelectedItemPage(state.selectedItem); +} diff --git a/crates/client-web/src/app/mediaExtras.ts b/crates/client-web/src/app/mediaExtras.ts new file mode 100644 index 00000000..c3079d78 --- /dev/null +++ b/crates/client-web/src/app/mediaExtras.ts @@ -0,0 +1,33 @@ +import type { MediaItemExtra } from '../api'; +import { formatMediaTime } from './format'; +import type { TrailerOption } from './types'; + +/** Converts a media-extra type identifier into a display label. */ +export function mediaExtraTypeLabel(extraType: string): string { + return extraType + .split('_') + .filter(Boolean) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(' ') || 'Extra'; +} + +/** Returns the best title for an extra, falling back to its type label. */ +export function mediaExtraTitle(extra: MediaItemExtra): string { + return extra.title?.trim() || mediaExtraTypeLabel(extra.extra_type); +} + +/** Formats an extra duration for display. */ +export function mediaExtraDurationLabel(extra: MediaItemExtra): string { + return typeof extra.duration_seconds === 'number' && extra.duration_seconds > 0 + ? formatMediaTime(extra.duration_seconds) + : 'Unknown length'; +} + +/** Converts a trailer extra into the overlay player's trailer option shape. */ +export function mediaExtraToTrailerOption(extra: MediaItemExtra): TrailerOption { + return { + title: mediaExtraTitle(extra), + url: extra.url, + label: mediaExtraTypeLabel(extra.extra_type), + }; +} diff --git a/crates/client-web/src/app/mediaTargets.ts b/crates/client-web/src/app/mediaTargets.ts new file mode 100644 index 00000000..ca56dd3f --- /dev/null +++ b/crates/client-web/src/app/mediaTargets.ts @@ -0,0 +1,63 @@ +import { state } from './state'; +import { collectionSummaries } from './selectors'; +import type { TrailerOption } from './types'; +import { extractYouTubeVideoId } from './youtube'; +import { mediaExtraToTrailerOption } from './mediaExtras'; + +export function currentTrailerOptions(): TrailerOption[] { + const options: TrailerOption[] = []; + const seenUrls = new Set(); + + if (state.selectedItem?.trailer_url) { + const url = state.selectedItem.trailer_url; + seenUrls.add(url); + options.push({ + title: state.selectedItem.trailer_title?.trim() || 'Trailer', + url, + }); + } + + for (const extra of state.selectedItem?.extras ?? []) { + if (extra.extra_type !== 'trailer' || !extra.url || seenUrls.has(extra.url)) { + continue; + } + + seenUrls.add(extra.url); + options.push(mediaExtraToTrailerOption(extra)); + } + + return options; +} + +export function currentThemeSongTarget(): { title: string; url: string } | undefined { + const route = state.route; + if (route.page === 'browse-detail' && route.kind === 'collection') { + const collection = collectionSummaries().find((entry) => entry.id === route.key); + return collection?.theme_song_url + ? { title: collection.name, url: collection.theme_song_url } + : undefined; + } + + if (route.page !== 'item' || !state.selectedItem?.theme_song_url) { + return undefined; + } + + return { + title: state.selectedItem.display_title, + url: state.selectedItem.theme_song_url, + }; +} + +export function currentThemeSongYouTubeTarget(): { title: string; url: string; videoId: string } | undefined { + const target = currentThemeSongTarget(); + const videoId = target ? extractYouTubeVideoId(target.url) : undefined; + if (!target || !videoId) { + return undefined; + } + + return { + title: target.title, + url: target.url, + videoId, + }; +} diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts new file mode 100644 index 00000000..30f6427c --- /dev/null +++ b/crates/client-web/src/app/playbackController.ts @@ -0,0 +1,1385 @@ +/** Controls trailer, theme-song, and browser playback UI state. */ +import { createIcons, icons } from 'lucide'; +import type { AppIconName, ThemeSongSource, TrailerOption, YouTubePlayer } from './types'; +import type { MediaAudioTrack, MediaItemDetail, PlaybackSession } from '../api'; +import { + createPlaybackSession, + deletePlaybackSession, + getArtworkUrl, + getItem, + getSessionStreamUrl, + getWebClientProfile, + resolveApiUrl, + updatePlaybackProgress, +} from '../api'; +import { YOUTUBE_PLAYER_STATE, YOUTUBE_THEME_PLACEHOLDER_VIDEO_ID } from './constants'; +import { escapeHtml, formatDuration, formatMediaTime } from './format'; +import { currentThemeSongTarget } from './mediaTargets'; +import { state } from './state'; +import { renderButtonContent, renderIcon, subtitleLanguage } from './ui'; +import { buildYouTubeWatchUrl, extractYouTubeVideoId, loadYouTubeIframeApi } from './youtube'; + +type RenderCallback = (preserveScroll?: boolean) => void; +type RefreshDataCallback = (showLoading?: boolean) => Promise; + +let renderApp: RenderCallback = () => undefined; +let refreshAppData: RefreshDataCallback = async () => undefined; + +/** Connects playback side effects back to the application coordinator. */ +export function configurePlaybackController(callbacks: { render: RenderCallback; refreshData: RefreshDataCallback }): void { + renderApp = callbacks.render; + refreshAppData = callbacks.refreshData; +} + +function render(preserveScroll = true): void { + renderApp(preserveScroll); +} + +function refreshData(showLoading = true): Promise { + return refreshAppData(showLoading); +} + +let themeSongYouTubePlayer: YouTubePlayer | undefined; + +let themeSongYouTubePlayerReady: Promise | undefined; + +let activeThemeSongYouTubeVideoId: string | undefined; + +let trailerYouTubePlayer: YouTubePlayer | undefined; + +let trailerYouTubePlayerReady: Promise | undefined; + +let activeTrailerYouTubeVideoId: string | undefined; + +let trailerProgressHandle: number | undefined; + +let trailerVolume = 1; + +let trailerMuted = false; + +const ESCALATING_SEEK_STEPS = [10, 20, 30, 60, 120, 300] as const; +const ESCALATING_SEEK_WINDOW_MS = 900; + +/** Renders the active playback overlay, including trailers and browser playback. */ +export function renderPlayerOverlay(): string { + return state.activeTrailer ? renderTrailerOverlay() : renderMediaPlayerOverlay(); +} + +function renderTrailerControlsMarkup(videoId: string | undefined): string { + if (!videoId) { + return ''; + } + + return ` +
+ +
+
+ 0:00/0:00 +
+
+ + + +
+
+ + + +
+
+
+ `; +} + +function renderTrailerFrameMarkup(videoId: string | undefined): string { + return videoId + ? '
' + : '
This external media URL is not a controllable YouTube video.
'; +} + +function renderTrailerTitleMarkup( + itemLogoUrl: string | undefined, + itemTitle: string | undefined, + trailerTitle: string, + brandedTrailerTitle: string, +): string { + if (itemLogoUrl) { + return ` +
+ +

${escapeHtml(brandedTrailerTitle)}

+
+ `; + } + + return `

${escapeHtml(trailerTitle)}

`; +} + +function renderTrailerOverlay(): string { + const activeTrailer = state.activeTrailer; + if (!activeTrailer) { + return ''; + } + + const videoId = extractYouTubeVideoId(activeTrailer.url); + const watchUrl = buildYouTubeWatchUrl(activeTrailer.url); + const label = activeTrailer.label ?? 'Trailer'; + const externalUrl = watchUrl ?? activeTrailer.url; + const externalLinkLabel = watchUrl ? 'Open on YouTube' : 'Open Source'; + const errorHint = watchUrl ? 'Open it on YouTube or try again in a moment.' : 'Open the source link or try another extra.'; + const itemTitle = state.selectedItem?.display_title.trim(); + const itemLogoUrl = state.selectedItem?.logo_url ? resolveApiUrl(state.selectedItem.logo_url) : undefined; + const trailerTitle = itemLogoUrl || !itemTitle + ? activeTrailer.title + : `${itemTitle} | ${activeTrailer.title}`; + const trailerControlsMarkup = renderTrailerControlsMarkup(videoId); + return ` +
+
+
+ ${renderTrailerFrameMarkup(videoId)} +
+ +
+ +
+
+ ${escapeHtml(label)} could not start + ${escapeHtml(errorHint)} +
+ +
+
+ ${escapeHtml(label)} + ${renderTrailerTitleMarkup(itemLogoUrl, itemTitle, trailerTitle, activeTrailer.title)} +
+
+ ${externalUrl ? `${renderButtonContent(externalLinkLabel, 'arrow-right')}` : ''} + +
+
+ ${trailerControlsMarkup} +
+
+ `; +} + +function renderSubtitleTrackMarkup(playbackItem: MediaItemDetail, isAudio: boolean): string { + if (isAudio) { + return ''; + } + + return playbackItem.subtitle_tracks + .map((track) => ``) + .join(''); +} + +function renderMediaElementMarkup(isAudio: boolean, source: string, posterUrl: string | undefined, trackMarkup: string): string { + if (!isAudio) { + return ` + + `; + } + + const audioArtMarkup = posterUrl + ? `` + : renderIcon('music', 'audio-player-art-icon'); + const audioArtClass = posterUrl ? 'has-image' : ''; + return ` + +
+ ${audioArtMarkup} +
+ + `; +} + +function activeAudioTrackForSelection(audioTracks: MediaAudioTrack[], selectedAudioStreamIndex: number | undefined): MediaAudioTrack | undefined { + return audioTracks.find((track) => track.index === selectedAudioStreamIndex) + ?? audioTracks.find((track) => track.default) + ?? audioTracks[0]; +} + +function renderAudioTrackOptions(audioTracks: MediaAudioTrack[], activeAudioTrack: MediaAudioTrack | undefined): string { + return audioTracks.map((track) => { + const isActiveTrack = track.index === activeAudioTrack?.index; + const activeTrackClass = isActiveTrack ? 'active' : ''; + const activeTrackChecked = isActiveTrack ? 'true' : 'false'; + const trackDetail = [track.language?.toUpperCase(), track.codec?.toUpperCase()].filter(Boolean).join(' · ') + || (track.default ? 'Default' : 'Audio'); + return ` + + `; + }).join(''); +} + +function renderAudioTrackMenuMarkup(isAudio: boolean, audioTracks: MediaAudioTrack[], activeAudioTrack: MediaAudioTrack | undefined): string { + if (isAudio || audioTracks.length <= 1) { + return ''; + } + + const audioTrackMenuTitle = activeAudioTrack + ? `Audio track: ${activeAudioTrack.label}` + : 'Audio track changes may require remuxing'; + const audioTrackMenuExpanded = state.isAudioTrackMenuOpen ? 'true' : 'false'; + const audioTrackMenuClass = state.isAudioTrackMenuOpen ? '' : 'is-hidden'; + const audioTrackMenuHidden = state.isAudioTrackMenuOpen ? '' : 'hidden'; + return ` +
+ + +
+ `; +} + +function renderPlayerTitleMarkup(logoUrl: string | undefined, title: string): string { + return logoUrl + ? `` + : `

${escapeHtml(title)}

`; +} + +function renderTranscodeBadge(isRemuxingForAudio: boolean): string { + const session = state.activePlaybackSession; + if (!session) { + return ''; + } + + const transcodeReason = isRemuxingForAudio + ? 'Using a non-default audio track requires a remuxed stream.' + : session.decision.reason; + return session.decision.transcode_required || isRemuxingForAudio + ? `Transcoding` + : `Direct Play`; +} + +function selectedAudioStreamIndexForPlayback(session: PlaybackSession): number | undefined { + return state.activeAudioStreamIndex ?? session.audio_stream_index; +} + +function playbackPosterUrl(playbackItem: MediaItemDetail): string | undefined { + return playbackItem.poster_url + ? getArtworkUrl(playbackItem.id, 'poster', playbackItem.artwork_updated_at) + : undefined; +} + +function playbackBackdropUrl(playbackItem: MediaItemDetail, posterUrl: string | undefined): string | undefined { + return playbackItem.backdrop_url + ? getArtworkUrl(playbackItem.id, 'backdrop', playbackItem.artwork_updated_at) + : posterUrl; +} + +function mediaStreamStartMs(session: PlaybackSession, isRemuxingForAudio: boolean): number { + return session.decision.transcode_required || isRemuxingForAudio + ? state.activePlaybackStartMs + : 0; +} + +function mediaPlayerShellClass(isAudio: boolean): string { + return isAudio ? 'audio-player-shell' : 'video-player-shell'; +} + +function playerBackdropStyle(backdropUrl: string | undefined): string { + return backdropUrl ? `style="--player-backdrop-image: url('${escapeHtml(backdropUrl)}');"` : ''; +} + +function renderPictureInPictureButton(isAudio: boolean): string { + return isAudio + ? '' + : ``; +} + +function renderMediaPlayerOverlay(): string { + const playbackItem = state.activePlaybackItem ?? state.selectedItem; + const playbackSession = state.activePlaybackSession; + if (!state.isPlayerOpen || !playbackItem || !playbackSession) { + return ''; + } + + const isAudio = playbackItem.media_kind === 'audio'; + const selectedAudioStreamIndex = selectedAudioStreamIndexForPlayback(playbackSession); + const posterUrl = playbackPosterUrl(playbackItem); + const backdropUrl = playbackBackdropUrl(playbackItem, posterUrl); + const logoUrl = playbackItem.logo_url ? resolveApiUrl(playbackItem.logo_url) : undefined; + const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; + const isRemuxingForAudio = isAudioStreamOverride && !playbackSession.decision.transcode_required; + const source = getSessionStreamUrl(playbackSession.session_id, mediaStreamStartMs(playbackSession, isRemuxingForAudio), selectedAudioStreamIndex); + const audioTracks = playbackItem.audio_tracks ?? []; + const activeAudioTrack = activeAudioTrackForSelection(audioTracks, selectedAudioStreamIndex); + const mediaElementMarkup = renderMediaElementMarkup(isAudio, source, posterUrl, renderSubtitleTrackMarkup(playbackItem, isAudio)); + const audioTrackMenuMarkup = renderAudioTrackMenuMarkup(isAudio, audioTracks, activeAudioTrack); + + return ` +
+
+ ${mediaElementMarkup} +
+ +
+
+ Playback could not start + Try another audio track or start playback again. +
+ +
+
+ Now playing + ${renderPlayerTitleMarkup(logoUrl, playbackItem.display_title)} +
+
+ ${renderTranscodeBadge(isRemuxingForAudio)} + +
+
+
+ +
+
+ 0:00/${escapeHtml(formatDuration(playbackItem.duration_ms))} +
+
+ + + +
+
+ + + ${audioTrackMenuMarkup} + ${renderPictureInPictureButton(isAudio)} + +
+
+
+
+
+ `; +} + +function themeSongLayer(): HTMLElement { + let layer = document.querySelector('#theme-song-layer'); + if (!layer) { + layer = document.createElement('div'); + layer.id = 'theme-song-layer'; + document.body.appendChild(layer); + } + + return layer; +} + +function currentThemeSongSource(): ThemeSongSource | undefined { + if (state.isPlayerOpen || state.activeTrailer) { + return undefined; + } + + const target = currentThemeSongTarget(); + return target ? themeSongSourceFromUrl(target.url, target.title) : undefined; +} + +/** Opens a video overlay for an arbitrary playable trailer or extra option. */ +export function openVideoOverlay(option: TrailerOption | undefined): void { + if (!option) { + return; + } + + destroyTrailerYouTubePlayer(); + state.activeTrailer = option; + state.isTrailerMenuOpen = false; + render(); +} + +/** Opens the selected trailer option in the trailer overlay. */ +export function openTrailer(option: TrailerOption | undefined): void { + openVideoOverlay(option); +} + +/** Closes the trailer overlay and tears down trailer playback resources. */ +export function closeTrailerPlayer(): void { + state.activeTrailer = undefined; + destroyTrailerYouTubePlayer(); + render(); +} + +function themeSongSourceFromUrl( + themeSongUrl: string, + title: string, +): ThemeSongSource | undefined { + if (!themeSongUrl) { + return undefined; + } + + const videoId = extractYouTubeVideoId(themeSongUrl); + if (videoId) { + return { + kind: 'youtube', + src: videoId, + title, + videoId, + }; + } + + return { + kind: 'audio', + src: resolveApiUrl(themeSongUrl), + title, + }; +} + +function clearTrailerProgressHandle(): void { + if (trailerProgressHandle !== undefined) { + globalThis.clearInterval(trailerProgressHandle); + trailerProgressHandle = undefined; + } +} + +function destroyTrailerYouTubePlayer(): void { + clearTrailerProgressHandle(); + trailerYouTubePlayerReady = undefined; + if (!trailerYouTubePlayer) { + activeTrailerYouTubeVideoId = undefined; + document.body.style.cursor = ''; + return; + } + + try { + trailerYouTubePlayer.pauseVideo(); + trailerYouTubePlayer.destroy(); + } catch { + // The trailer iframe may already have been removed during a render. + } finally { + trailerYouTubePlayer = undefined; + activeTrailerYouTubeVideoId = undefined; + document.body.style.cursor = ''; + } +} + +function destroyThemeSongYouTubePlayer(): void { + themeSongYouTubePlayerReady = undefined; + if (!themeSongYouTubePlayer) { + return; + } + + try { + themeSongYouTubePlayer.pauseVideo(); + themeSongYouTubePlayer.destroy(); + } catch { + // The YouTube iframe may already have been removed during a render. + } finally { + themeSongYouTubePlayer = undefined; + activeThemeSongYouTubeVideoId = undefined; + } +} + +function ensureThemeSongYouTubePlayer(): Promise { + if (themeSongYouTubePlayer) { + return Promise.resolve(themeSongYouTubePlayer); + } + + if (themeSongYouTubePlayerReady) { + return themeSongYouTubePlayerReady; + } + + const layer = themeSongLayer(); + if (!document.querySelector('#theme-song-youtube-player')) { + layer.innerHTML = '
'; + } + + themeSongYouTubePlayerReady = loadYouTubeIframeApi().then((api) => new Promise((resolve) => { + themeSongYouTubePlayer = new api.Player('theme-song-youtube-player', { + height: '0', + width: '0', + videoId: YOUTUBE_THEME_PLACEHOLDER_VIDEO_ID, + playerVars: { + autoplay: 0, + controls: 2, + loop: 0, + }, + events: { + onReady: (event) => { + event.target.setPlaybackQuality('small'); + resolve(event.target); + }, + onStateChange: () => { + if (state.hasDeferredAutoRefreshRender) { + state.hasDeferredAutoRefreshRender = false; + render(); + } + }, + onError: (event) => { + console.warn('YouTube theme song playback failed', { + videoId: activeThemeSongYouTubeVideoId, + errorCode: event.data, + }); + }, + }, + }); + })); + + return themeSongYouTubePlayerReady; +} + +/** Starts playback for the selected YouTube theme-song video. */ +export function playYouTubeThemeSong(videoId: string): void { + activeThemeSongYouTubeVideoId = videoId; + if (themeSongYouTubePlayer) { + themeSongYouTubePlayer.loadVideoById(videoId); + return; + } + + void ensureThemeSongYouTubePlayer().then((player) => { + player.loadVideoById(videoId); + }); +} + +/** Synchronizes the theme-song player with the currently selected item. */ +export function syncThemeSongPlayer(): void { + const layer = themeSongLayer(); + const source = currentThemeSongSource(); + if (!source) { + destroyThemeSongYouTubePlayer(); + layer.replaceChildren(); + delete layer.dataset.themeKind; + delete layer.dataset.themeSrc; + return; + } + + if (layer.hasChildNodes() && layer.dataset.themeKind === source.kind && layer.dataset.themeSrc === source.src) { + return; + } + + layer.dataset.themeKind = source.kind; + layer.dataset.themeSrc = source.src; + if (source.kind === 'youtube') { + if (!document.querySelector('#theme-song-youtube-player')) { + layer.innerHTML = '
'; + } + playYouTubeThemeSong(source.videoId); + return; + } + + destroyThemeSongYouTubePlayer(); + layer.innerHTML = ``; + const themePlayer = layer.querySelector('#theme-song-player'); + if (!themePlayer) { + return; + } + + themePlayer.volume = 0.45; + themePlayer.loop = false; + themePlayer.addEventListener('ended', () => { + if (state.hasDeferredAutoRefreshRender) { + state.hasDeferredAutoRefreshRender = false; + render(); + } + }, { once: true }); + void themePlayer.play().catch(() => { + // Autoplay can be blocked by the browser, so the page quietly falls back without looping. + }); +} + +/** Stops any active browser playback session and clears playback state. */ +export function closeActivePlaybackSession(): void { + state.isPlayerOpen = false; + document.body.style.cursor = ''; + const sessionToClose = state.activePlaybackSession; + state.activePlaybackItem = undefined; + state.activePlaybackSession = undefined; + state.activePlaybackStartMs = 0; + state.activeAudioStreamIndex = undefined; + state.isAudioTrackMenuOpen = false; + render(); + if (sessionToClose) { + deletePlaybackSession(sessionToClose.session_id) + .catch((error) => { + console.error('Failed to close playback session', error); + }) + .finally(() => { + void refreshData(false); + }); + } else { + void refreshData(false); + } +} + +/** Starts a browser playback session for an already-loaded media item. */ +export async function startPlayback(item: MediaItemDetail, startMs: number): Promise { + const previousSession = state.activePlaybackSession; + state.activePlaybackSession = undefined; + state.activePlaybackItem = item; + state.activePlaybackStartMs = Math.max(0, startMs); + state.isPlayerOpen = true; + state.activeAudioStreamIndex = undefined; + state.isAudioTrackMenuOpen = false; + render(); + + if (previousSession) { + deletePlaybackSession(previousSession.session_id).catch((error) => { + console.error('Failed to replace playback session', error); + }); + } + + state.activePlaybackSession = await createPlaybackSession({ + item_id: item.id, + client_profile: getWebClientProfile(), + }); + render(); +} + +/** Loads an item by id and starts a browser playback session for it. */ +export async function startPlaybackForItemId(itemId: number, startMs: number): Promise { + const item = state.selectedItem?.id === itemId + ? state.selectedItem + : await getItem(itemId); + await startPlayback(item, startMs); +} + +function ensureTrailerYouTubePlayer(videoId: string): Promise { + if (trailerYouTubePlayer && activeTrailerYouTubeVideoId === videoId) { + return Promise.resolve(trailerYouTubePlayer); + } + + if (trailerYouTubePlayerReady && activeTrailerYouTubeVideoId === videoId) { + return trailerYouTubePlayerReady; + } + + destroyTrailerYouTubePlayer(); + activeTrailerYouTubeVideoId = videoId; + const playerVars: Record = { + autoplay: 1, + controls: 0, + disablekb: 1, + fs: 0, + iv_load_policy: 3, + loop: 0, + modestbranding: 1, + playsinline: 1, + rel: 0, + }; + if (globalThis.location.origin.startsWith('http')) { + playerVars.origin = globalThis.location.origin; + } + + trailerYouTubePlayerReady = loadYouTubeIframeApi().then((api) => new Promise((resolve) => { + trailerYouTubePlayer = new api.Player('trailer-player', { + height: '100%', + width: '100%', + videoId, + playerVars, + events: { + onReady: (event) => { + trailerYouTubePlayer = event.target; + event.target.setPlaybackQuality('hd720'); + event.target.setVolume(Math.round(trailerVolume * 100)); + if (trailerMuted) { + event.target.mute(); + } else { + event.target.unMute(); + } + event.target.playVideo(); + resolve(event.target); + }, + onStateChange: () => { + updateTrailerPlayerUi(); + }, + onError: (event) => { + document.querySelector('.trailer-shell')?.classList.add('has-media-error'); + document.querySelector('.trailer-shell')?.classList.remove('is-media-loading'); + console.warn('YouTube trailer playback failed', { + videoId: activeTrailerYouTubeVideoId, + errorCode: event.data, + }); + }, + }, + }); + })); + + return trailerYouTubePlayerReady; +} + +function trailerPlayerState(): number | undefined { + try { + return trailerYouTubePlayer?.getPlayerState(); + } catch { + return undefined; + } +} + +function isTrailerPlaying(): boolean { + return trailerPlayerState() === YOUTUBE_PLAYER_STATE.playing; +} + +function updateIconButton( + button: HTMLButtonElement | null | undefined, + iconName: AppIconName, + label: string, +): void { + if (!button) { + return; + } + button.innerHTML = renderIcon(iconName, 'player-control-icon'); + button.title = label; + button.setAttribute('aria-label', label); + createIcons({ icons }); +} + +/** Creates a seek handler that increases the step when repeated in one direction. */ +function createEscalatingSeekHandler(seekBy: (seconds: number) => void): (direction: number) => void { + let lastSkipDirection = 0; + let lastSkipAt = 0; + let skipStepIndex = 0; + + return (direction: number): void => { + const now = Date.now(); + if (direction !== 0 && direction === lastSkipDirection && now - lastSkipAt < ESCALATING_SEEK_WINDOW_MS) { + skipStepIndex = Math.min(ESCALATING_SEEK_STEPS.length - 1, skipStepIndex + 1); + } else { + skipStepIndex = 0; + } + lastSkipDirection = direction; + lastSkipAt = now; + seekBy(direction * ESCALATING_SEEK_STEPS[skipStepIndex]); + }; +} + +function updateTrailerPlayerUi(): void { + const player = trailerYouTubePlayer; + if (!player) { + return; + } + + const shell = document.querySelector('.trailer-shell'); + const progress = document.querySelector('#trailer-progress'); + const currentTimeLabel = document.querySelector('#trailer-current-time'); + const durationLabel = document.querySelector('#trailer-duration'); + const playButtons = Array.from(document.querySelectorAll('#trailer-play-toggle-small')); + const muteButton = document.querySelector('#trailer-mute-toggle'); + const volume = document.querySelector('#trailer-volume'); + const playerState = trailerPlayerState(); + const isPlaying = playerState === YOUTUBE_PLAYER_STATE.playing; + const isLoading = playerState === YOUTUBE_PLAYER_STATE.buffering || playerState === YOUTUBE_PLAYER_STATE.cued; + const duration = player.getDuration(); + const currentTime = player.getCurrentTime(); + + shell?.classList.toggle('is-media-loading', isLoading); + playButtons.forEach((button) => updateIconButton(button, isPlaying ? 'pause' : 'play', isPlaying ? 'Pause' : 'Play')); + trailerMuted = player.isMuted() || player.getVolume() === 0; + trailerVolume = Math.max(0, Math.min(1, player.getVolume() / 100)); + updateIconButton(muteButton, trailerMuted ? 'volume-x' : 'volume-2', trailerMuted ? 'Unmute' : 'Mute'); + if (volume && document.activeElement !== volume) { + volume.value = String(trailerMuted ? 0 : trailerVolume); + } + if (progress && progress.dataset.scrubbing !== 'true') { + progress.value = duration > 0 ? String(Math.min(1000, Math.max(0, (currentTime / duration) * 1000))) : '0'; + } + if (currentTimeLabel) { + currentTimeLabel.textContent = formatMediaTime(currentTime); + } + if (durationLabel) { + durationLabel.textContent = formatMediaTime(duration); + } +} + +/** Binds controls for the trailer overlay player in the current DOM tree. */ +export function bindTrailerPlayer(): void { + if (!state.activeTrailer) { + destroyTrailerYouTubePlayer(); + return; + } + + const shell = document.querySelector('.trailer-shell'); + const videoId = shell?.dataset.trailerVideoId; + if (!shell || !videoId) { + return; + } + + const progress = document.querySelector('#trailer-progress'); + const volume = document.querySelector('#trailer-volume'); + const currentTimeLabel = document.querySelector('#trailer-current-time'); + const playButtons = Array.from(document.querySelectorAll('#trailer-play-toggle-small')); + const muteButton = document.querySelector('#trailer-mute-toggle'); + const fullscreenButton = document.querySelector('#trailer-fullscreen'); + const idleHitArea = document.querySelector('.trailer-idle-hit-area'); + let controlsHideHandle: number | undefined; + let isScrubbing = false; + + const withTrailerPlayer = (action: (player: YouTubePlayer) => void): void => { + if (trailerYouTubePlayer) { + action(trailerYouTubePlayer); + updateTrailerPlayerUi(); + return; + } + void ensureTrailerYouTubePlayer(videoId).then((player) => { + action(player); + updateTrailerPlayerUi(); + }); + }; + + const showControls = (): void => { + shell.classList.add('is-controls-visible'); + shell.classList.remove('is-controls-hidden'); + document.body.style.cursor = ''; + if (controlsHideHandle !== undefined) { + globalThis.clearTimeout(controlsHideHandle); + } + controlsHideHandle = globalThis.setTimeout(() => { + if (isTrailerPlaying() && !isScrubbing) { + shell.classList.remove('is-controls-visible'); + shell.classList.add('is-controls-hidden'); + document.body.style.cursor = 'none'; + } + }, 3200); + }; + + const seekBy = (seconds: number): void => { + withTrailerPlayer((player) => { + const duration = player.getDuration(); + const currentTime = player.getCurrentTime(); + const targetTime = duration > 0 + ? Math.min(duration, Math.max(0, currentTime + seconds)) + : Math.max(0, currentTime + seconds); + player.seekTo(targetTime, true); + }); + }; + + const seekWithEscalation = createEscalatingSeekHandler(seekBy); + + const togglePlayback = (): void => { + withTrailerPlayer((player) => { + if (player.getPlayerState() === YOUTUBE_PLAYER_STATE.playing) { + player.pauseVideo(); + } else { + player.playVideo(); + } + }); + }; + + const toggleFullscreen = (): void => { + if (document.fullscreenElement) { + void document.exitFullscreen(); + return; + } + void shell.requestFullscreen?.(); + }; + + shell.focus({ preventScroll: true }); + ['mousemove', 'mousedown', 'touchstart', 'pointermove'].forEach((eventName) => { + shell.addEventListener(eventName, showControls, { passive: true }); + }); + shell.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLInputElement) { + return; + } + if (event.key === ' ' || event.key === 'k') { + event.preventDefault(); + togglePlayback(); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + seekWithEscalation(-1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + seekWithEscalation(1); + } else if (event.key === 'm') { + event.preventDefault(); + muteButton?.click(); + } else if (event.key === 'f') { + event.preventDefault(); + toggleFullscreen(); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeTrailerPlayer(); + } + showControls(); + }); + + idleHitArea?.addEventListener('click', () => { + togglePlayback(); + showControls(); + }); + playButtons.forEach((button) => button.addEventListener('click', () => { + togglePlayback(); + showControls(); + })); + document.querySelectorAll('[data-trailer-seek]').forEach((button) => { + button.addEventListener('click', () => { + const requestedSeconds = Number(button.dataset.trailerSeek); + const direction = Math.sign(requestedSeconds); + if (direction !== 0) { + seekWithEscalation(direction); + } + showControls(); + }); + }); + muteButton?.addEventListener('click', () => { + withTrailerPlayer((player) => { + if (player.isMuted() || player.getVolume() === 0) { + player.unMute(); + if (player.getVolume() === 0) { + player.setVolume(Math.round(Math.max(trailerVolume, 0.45) * 100)); + } + } else { + player.mute(); + } + }); + showControls(); + }); + volume?.addEventListener('input', () => { + const nextVolume = Math.min(1, Math.max(0, Number(volume.value))); + trailerVolume = nextVolume; + withTrailerPlayer((player) => { + player.setVolume(Math.round(nextVolume * 100)); + if (nextVolume <= 0) { + player.mute(); + } else { + player.unMute(); + } + }); + showControls(); + }); + volume?.addEventListener('wheel', (event) => { + event.preventDefault(); + const delta = event.deltaY < 0 ? 0.05 : -0.05; + const nextVolume = Math.min(1, Math.max(0, trailerVolume + delta)); + trailerVolume = nextVolume; + withTrailerPlayer((player) => { + player.setVolume(Math.round(nextVolume * 100)); + if (nextVolume <= 0) { + player.mute(); + } else { + player.unMute(); + } + }); + showControls(); + }, { passive: false }); + fullscreenButton?.addEventListener('click', () => { + toggleFullscreen(); + showControls(); + }); + progress?.addEventListener('input', () => { + isScrubbing = true; + progress.dataset.scrubbing = 'true'; + if (!trailerYouTubePlayer) { + return; + } + const duration = trailerYouTubePlayer.getDuration(); + if (duration > 0 && currentTimeLabel) { + currentTimeLabel.textContent = formatMediaTime((Number(progress.value) / 1000) * duration); + } + showControls(); + }); + progress?.addEventListener('wheel', (event) => { + event.preventDefault(); + seekWithEscalation(event.deltaY < 0 ? 1 : -1); + showControls(); + }, { passive: false }); + progress?.addEventListener('change', () => { + if (trailerYouTubePlayer) { + const duration = trailerYouTubePlayer.getDuration(); + if (duration > 0) { + trailerYouTubePlayer.seekTo((Number(progress.value) / 1000) * duration, true); + } + } + isScrubbing = false; + delete progress.dataset.scrubbing; + updateTrailerPlayerUi(); + showControls(); + }); + + shell.classList.add('is-media-loading'); + void ensureTrailerYouTubePlayer(videoId).then((player) => { + player.playVideo(); + updateTrailerPlayerUi(); + clearTrailerProgressHandle(); + trailerProgressHandle = globalThis.setInterval(updateTrailerPlayerUi, 500); + showControls(); + }); +} + +/** Binds controls for the browser playback overlay in the current DOM tree. */ +export function bindPlayerProgress(): void { + const player = document.querySelector('#media-player'); + const playbackItem = state.activePlaybackItem ?? state.selectedItem; + if (!player || !playbackItem) { + return; + } + + const shell = document.querySelector('.media-player-shell'); + const progress = document.querySelector('#player-progress'); + const volume = document.querySelector('#player-volume'); + const currentTimeLabel = document.querySelector('#player-current-time'); + const durationLabel = document.querySelector('#player-duration'); + const playButtons = Array.from(document.querySelectorAll('#player-play-toggle-small')); + const muteButton = document.querySelector('#player-mute-toggle'); + const fullscreenButton = document.querySelector('#player-fullscreen'); + const pipButton = document.querySelector('#player-pip'); + const audioTrackToggle = document.querySelector('#player-audio-track-toggle'); + const audioTrackMenu = document.querySelector('#player-audio-track-menu'); + const selectedAudioStreamIndex = state.activeAudioStreamIndex ?? state.activePlaybackSession?.audio_stream_index; + const currentAudioTrackIndex = selectedAudioStreamIndex ?? 0; + const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; + const isTranscoding = (state.activePlaybackSession?.decision.transcode_required ?? false) || isAudioStreamOverride; + const sourceDurationSeconds = (playbackItem.duration_ms ?? 0) / 1000; + const requestedPlaybackStartSeconds = Math.max(0, state.activePlaybackStartMs / 1000); + const playbackBaseOffsetSeconds = isTranscoding ? requestedPlaybackStartSeconds : 0; + const initialDirectSeekSeconds = isTranscoding ? 0 : requestedPlaybackStartSeconds; + let controlsHideHandle: number | undefined; + let isScrubbing = false; + let hasAppliedInitialDirectSeek = initialDirectSeekSeconds <= 0; + + const playbackDurationSeconds = (): number => { + if (sourceDurationSeconds > 0) { + return sourceDurationSeconds; + } + if (Number.isFinite(player.duration) && player.duration > 0) { + return player.duration; + } + return 0; + }; + + const setPlayerLoading = (loading: boolean): void => { + const shouldShowLoading = loading && !player.ended && player.readyState < player.HAVE_FUTURE_DATA; + shell?.classList.toggle('is-media-loading', shouldShowLoading); + shell?.classList.remove('has-media-error'); + }; + + const refreshPlayerLoading = (): void => { + setPlayerLoading(!player.paused && player.readyState < player.HAVE_FUTURE_DATA); + }; + + const setPlayerError = (): void => { + shell?.classList.remove('is-media-loading'); + shell?.classList.add('has-media-error'); + }; + + const updatePlayButtons = (): void => { + const iconName: AppIconName = player.paused ? 'play' : 'pause'; + const label = player.paused ? 'Play' : 'Pause'; + playButtons.forEach((button) => updateIconButton(button, iconName, label)); + }; + + const updateMuteButton = (): void => { + updateIconButton(muteButton, player.muted || player.volume === 0 ? 'volume-x' : 'volume-2', player.muted ? 'Unmute' : 'Mute'); + if (volume && !isScrubbing) { + volume.value = String(player.muted ? 0 : player.volume); + } + }; + + const updatePipButton = (): void => { + if (!pipButton || !(player instanceof HTMLVideoElement)) { + return; + } + const isSupported = document.pictureInPictureEnabled && !player.disablePictureInPicture; + pipButton.disabled = !isSupported; + pipButton.title = isSupported ? 'Picture in picture' : 'Picture in picture is not available in this browser'; + pipButton.setAttribute('aria-label', pipButton.title); + }; + + const setAudioTrackMenuOpen = (open: boolean): void => { + state.isAudioTrackMenuOpen = open; + audioTrackToggle?.setAttribute('aria-expanded', open ? 'true' : 'false'); + audioTrackMenu?.classList.toggle('is-hidden', !open); + audioTrackMenu?.toggleAttribute('hidden', !open); + }; + + const updateTimeline = (): void => { + const duration = playbackDurationSeconds(); + const currentPosition = Math.min(duration || Number.POSITIVE_INFINITY, playbackBaseOffsetSeconds + player.currentTime); + if (progress && !isScrubbing) { + progress.value = duration > 0 ? String(Math.min(1000, Math.max(0, (currentPosition / duration) * 1000))) : '0'; + } + if (currentTimeLabel) { + currentTimeLabel.textContent = formatMediaTime(currentPosition); + } + if (durationLabel) { + durationLabel.textContent = formatMediaTime(duration); + } + }; + + const applyInitialDirectSeek = (): void => { + if (hasAppliedInitialDirectSeek || initialDirectSeekSeconds <= 0 || player.readyState < player.HAVE_METADATA) { + return; + } + + const duration = playbackDurationSeconds(); + const targetPosition = duration > 0 + ? Math.min(initialDirectSeekSeconds, Math.max(0, duration - 1)) + : initialDirectSeekSeconds; + + try { + player.currentTime = targetPosition; + hasAppliedInitialDirectSeek = true; + updateTimeline(); + } catch (error) { + console.warn('Failed to seek direct-play item to resume position', error); + } + }; + + const showControls = (): void => { + shell?.classList.add('is-controls-visible'); + shell?.classList.remove('is-controls-hidden'); + document.body.style.cursor = ''; + if (controlsHideHandle !== undefined) { + globalThis.clearTimeout(controlsHideHandle); + } + controlsHideHandle = globalThis.setTimeout(() => { + if (!player.paused && !isScrubbing) { + shell?.classList.remove('is-controls-visible'); + shell?.classList.add('is-controls-hidden'); + document.body.style.cursor = 'none'; + } + }, 3200); + }; + + const seekBy = (seconds: number): void => { + const currentPosition = playbackBaseOffsetSeconds + player.currentTime; + const targetPosition = Math.max(0, currentPosition + seconds); + if (isTranscoding) { + state.activePlaybackStartMs = Math.floor(targetPosition * 1000); + render(false); + return; + } + if (!Number.isFinite(player.duration)) { + player.currentTime = targetPosition; + return; + } + player.currentTime = Math.min(player.duration, targetPosition); + }; + + const seekWithEscalation = createEscalatingSeekHandler(seekBy); + + const togglePlayback = (): void => { + if (player.paused) { + void player.play(); + } else { + player.pause(); + } + }; + + const toggleFullscreen = (): void => { + const fullscreenElement = document.fullscreenElement; + if (fullscreenElement) { + void document.exitFullscreen(); + return; + } + void shell?.requestFullscreen?.(); + }; + + shell?.focus({ preventScroll: true }); + ['mousemove', 'mousedown', 'touchstart', 'pointermove'].forEach((eventName) => { + shell?.addEventListener(eventName, showControls, { passive: true }); + }); + shell?.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLInputElement) { + return; + } + if (event.key === ' ' || event.key === 'k') { + event.preventDefault(); + togglePlayback(); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + seekBy(-10); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + seekBy(30); + } else if (event.key === 'm') { + event.preventDefault(); + player.muted = !player.muted; + updateMuteButton(); + } else if (event.key === 'f') { + event.preventDefault(); + toggleFullscreen(); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeActivePlaybackSession(); + } + showControls(); + }); + + playButtons.forEach((button) => button.addEventListener('click', () => { + togglePlayback(); + showControls(); + })); + document.querySelectorAll('[data-player-seek]').forEach((button) => { + button.addEventListener('click', () => { + const requestedSeconds = Number(button.dataset.playerSeek); + const direction = Math.sign(requestedSeconds); + if (direction !== 0) { + seekWithEscalation(direction); + } + showControls(); + }); + }); + muteButton?.addEventListener('click', () => { + player.muted = !player.muted; + updateMuteButton(); + showControls(); + }); + volume?.addEventListener('input', () => { + player.volume = Number(volume.value); + player.muted = player.volume === 0; + updateMuteButton(); + showControls(); + }); + volume?.addEventListener('wheel', (event) => { + event.preventDefault(); + const delta = event.deltaY < 0 ? 0.05 : -0.05; + player.volume = Math.min(1, Math.max(0, player.volume + delta)); + player.muted = player.volume === 0; + updateMuteButton(); + showControls(); + }, { passive: false }); + fullscreenButton?.addEventListener('click', () => { + toggleFullscreen(); + showControls(); + }); + audioTrackToggle?.addEventListener('click', () => { + setAudioTrackMenuOpen(!state.isAudioTrackMenuOpen); + showControls(); + }); + document.querySelectorAll('[data-player-audio-track-index]').forEach((button) => { + button.addEventListener('click', () => { + const nextAudioTrackIndex = Number(button.dataset.playerAudioTrackIndex); + if (!Number.isFinite(nextAudioTrackIndex)) { + return; + } + if (nextAudioTrackIndex === currentAudioTrackIndex) { + setAudioTrackMenuOpen(false); + showControls(); + return; + } + state.activeAudioStreamIndex = nextAudioTrackIndex; + state.activePlaybackStartMs = Math.floor((playbackBaseOffsetSeconds + player.currentTime) * 1000); + setAudioTrackMenuOpen(false); + render(false); + }); + }); + pipButton?.addEventListener('click', async () => { + if (!(player instanceof HTMLVideoElement) || !document.pictureInPictureEnabled) { + state.error = 'Picture in picture is not available in this browser.'; + render(); + return; + } + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + if (player.paused) { + void player.play(); + } + await player.requestPictureInPicture(); + shell?.classList.add('is-picture-in-picture'); + document.body.style.cursor = ''; + } catch (error) { + console.error('Failed to enter picture-in-picture', error); + state.error = error instanceof Error ? error.message : 'Failed to enter picture in picture.'; + render(); + } + }); + player.addEventListener('leavepictureinpicture', () => { + shell?.classList.remove('is-picture-in-picture'); + showControls(); + }); + progress?.addEventListener('input', () => { + isScrubbing = true; + const duration = playbackDurationSeconds(); + if (duration > 0) { + const previewSeconds = (Number(progress.value) / 1000) * duration; + if (currentTimeLabel) { + currentTimeLabel.textContent = formatMediaTime(previewSeconds); + } + } + showControls(); + }); + progress?.addEventListener('wheel', (event) => { + event.preventDefault(); + const direction = event.deltaY < 0 ? 1 : -1; + seekWithEscalation(direction); + updateTimeline(); + showControls(); + }, { passive: false }); + progress?.addEventListener('change', () => { + const duration = playbackDurationSeconds(); + if (duration > 0) { + const targetPosition = (Number(progress.value) / 1000) * duration; + if (isTranscoding) { + state.activePlaybackStartMs = Math.floor(targetPosition * 1000); + render(false); + return; + } + player.currentTime = targetPosition; + } + isScrubbing = false; + updateTimeline(); + showControls(); + }); + + let lastSentSeconds = -1; + player.addEventListener('loadstart', () => setPlayerLoading(true)); + player.addEventListener('waiting', refreshPlayerLoading); + player.addEventListener('stalled', refreshPlayerLoading); + player.addEventListener('loadeddata', () => setPlayerLoading(false)); + player.addEventListener('canplay', () => { + applyInitialDirectSeek(); + setPlayerLoading(false); + }); + player.addEventListener('playing', () => setPlayerLoading(false)); + player.addEventListener('error', () => { + setPlayerError(); + console.error('Media playback failed', player.error); + }); + player.addEventListener('loadedmetadata', () => { + applyInitialDirectSeek(); + updateTimeline(); + }); + player.addEventListener('play', () => { + updatePlayButtons(); + showControls(); + }); + player.addEventListener('pause', () => { + updatePlayButtons(); + showControls(); + }); + player.addEventListener('volumechange', updateMuteButton); + player.addEventListener('timeupdate', () => { + setPlayerLoading(false); + updateTimeline(); + const currentSeconds = Math.floor(player.currentTime); + if (currentSeconds === lastSentSeconds || currentSeconds % 15 !== 0) { + return; + } + + lastSentSeconds = currentSeconds; + void updatePlaybackProgress(playbackItem.id, { + position_ms: Math.floor((playbackBaseOffsetSeconds + player.currentTime) * 1000), + duration_ms: playbackItem.duration_ms ?? (Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : undefined), + completed: false, + }); + }); + + player.addEventListener('ended', () => { + updatePlayButtons(); + showControls(); + void updatePlaybackProgress(playbackItem.id, { + position_ms: playbackItem.duration_ms ?? Math.floor((playbackBaseOffsetSeconds + (Number.isFinite(player.duration) ? player.duration : 0)) * 1000), + duration_ms: playbackItem.duration_ms ?? (Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : undefined), + completed: true, + }); + }); + + updatePlayButtons(); + updateMuteButton(); + updatePipButton(); + updateTimeline(); + setPlayerLoading(player.readyState < player.HAVE_FUTURE_DATA); + showControls(); + void player.play().catch((error) => { + console.warn('Autoplay after opening player was blocked or failed', error); + updatePlayButtons(); + setPlayerLoading(false); + showControls(); + }); +} diff --git a/crates/client-web/src/app/playbackProgress.ts b/crates/client-web/src/app/playbackProgress.ts new file mode 100644 index 00000000..f65275d8 --- /dev/null +++ b/crates/client-web/src/app/playbackProgress.ts @@ -0,0 +1,32 @@ +import type { MediaItemDetail, MediaItemSummary } from '../api'; + +/** Returns a saved playback position only when it is useful as a resume target. */ +export function resumablePlaybackPositionMs(item: MediaItemDetail): number { + if (item.playback_completed) { + return 0; + } + const positionMs = item.playback_position_ms ?? 0; + const durationMs = item.playback_duration_ms ?? item.duration_ms ?? 0; + if (positionMs < 30_000) { + return 0; + } + if (durationMs > 0 && durationMs - positionMs < 30_000) { + return 0; + } + + return positionMs; +} + +/** Returns a 1-99 watch-progress percentage for cards, or undefined when hidden. */ +export function playbackProgressPercent(item: MediaItemSummary): number | undefined { + if (item.playback_completed) { + return undefined; + } + const positionMs = item.playback_position_ms ?? 0; + const durationMs = item.playback_duration_ms ?? item.duration_ms ?? 0; + if (positionMs < 30_000 || durationMs <= 0 || durationMs - positionMs < 30_000) { + return undefined; + } + + return Math.min(99, Math.max(1, Math.round((positionMs / durationMs) * 100))); +} diff --git a/crates/client-web/src/app/providers.ts b/crates/client-web/src/app/providers.ts new file mode 100644 index 00000000..b0a6dcc0 --- /dev/null +++ b/crates/client-web/src/app/providers.ts @@ -0,0 +1,21 @@ +import type { MetadataProviderStatus } from '../api'; +import { state } from './state'; + +export function providerAttributionLogo(providerId: string): string | undefined { + const provider = (state.selectedItemMetadata?.providers ?? state.metadataProviders) + .find((entry) => entry.id === providerId); + return provider?.logo_dark_url ?? provider?.logo_light_url; +} + +export function providerStatus(providerId: string): MetadataProviderStatus | undefined { + return state.metadataProviders.find((provider) => provider.id === providerId); +} + +export function providerDisplayName(providerId: string): string { + return providerStatus(providerId)?.display_name ?? providerId; +} + +export function libraryProviderOptions(libraryKind?: string): MetadataProviderStatus[] { + return state.metadataProviders + .filter((provider) => !libraryKind || provider.supported_kinds.includes(libraryKind)); +} diff --git a/crates/client-web/src/app/routes.ts b/crates/client-web/src/app/routes.ts new file mode 100644 index 00000000..1123c0e0 --- /dev/null +++ b/crates/client-web/src/app/routes.ts @@ -0,0 +1,63 @@ +/** Parses browser location state into typed app routes. */ +import type { AppRoute, HomeBrowseTab, SettingsSection } from './types'; + +/** Returns the initial home tab for a route. */ +export function defaultHomeTab(_route: AppRoute): HomeBrowseTab { + return 'recommended'; +} + +function browseKindFromSegment(segment: string): Extract['kind'] { + if (segment === 'collections') { + return 'collection'; + } + if (segment === 'playlists') { + return 'playlist'; + } + return 'category'; +} + +/** Converts the current browser path into the web UI's route model. */ +export function parseRoute(): AppRoute { + const normalizedPath = globalThis.location.pathname.replace(/\/+$/, '') || '/'; + + const settingsMatch = /^\/settings(?:\/(libraries|providers|scheduled|dashboard|logs))?$/.exec(normalizedPath); + if (settingsMatch) { + return { page: 'settings', section: (settingsMatch[1] as SettingsSection | undefined) ?? 'general' }; + } + + const itemMatch = /^\/items\/(\d+)$/.exec(normalizedPath); + if (itemMatch) { + return { page: 'item', itemId: Number(itemMatch[1]) }; + } + + const personMatch = /^\/people\/(\d+)$/.exec(normalizedPath); + if (personMatch) { + return { page: 'person', personId: Number(personMatch[1]) }; + } + + const libraryBrowseMatch = /^\/libraries\/(\d+)\/items\/(collections|categories|playlists)\/(.+)$/.exec(normalizedPath); + if (libraryBrowseMatch) { + return { + page: 'browse-detail', + libraryId: Number(libraryBrowseMatch[1]), + kind: browseKindFromSegment(libraryBrowseMatch[2]), + key: decodeURIComponent(libraryBrowseMatch[3]), + }; + } + + const browseMatch = /^\/items\/(collections|categories|playlists)\/(.+)$/.exec(normalizedPath); + if (browseMatch) { + return { + page: 'browse-detail', + kind: browseKindFromSegment(browseMatch[1]), + key: decodeURIComponent(browseMatch[2]), + }; + } + + const libraryMatch = /^\/libraries\/(\d+)$/.exec(normalizedPath); + if (libraryMatch) { + return { page: 'home', libraryId: Number(libraryMatch[1]) }; + } + + return { page: 'home' }; +} diff --git a/crates/client-web/src/app/selectors.ts b/crates/client-web/src/app/selectors.ts new file mode 100644 index 00000000..d9843f61 --- /dev/null +++ b/crates/client-web/src/app/selectors.ts @@ -0,0 +1,357 @@ +/** Provides derived state selectors for navigation, previews, and browse views. */ +import type { MediaCollectionSummary, MediaItemSummary, MediaLibrary, MediaLibrarySettings } from '../api'; +import { getArtworkUrl, resolveApiUrl } from '../api'; +import { state } from './state'; +import { humanizeItemType } from './ui'; + +export type HomeFeaturePreview = + | { kind: 'collection'; collection: MediaCollectionSummary } + | { kind: 'item'; item: MediaItemSummary }; + +export function activeLibraryId(): number | undefined { + if (state.route.page === 'home' || state.route.page === 'browse-detail') { + return state.route.libraryId; + } + + return state.selectedItem?.library_id; +} + +export function activeLibrary(): MediaLibrary | undefined { + return state.libraries.find((library) => library.id === activeLibraryId()); +} + +export function activeLibrarySettings(): MediaLibrarySettings | undefined { + const library = activeLibrary(); + if (!library || !state.settingsResponse) { + return undefined; + } + + const settingsWithPaths = state.settingsResponse.settings.media.libraries.map((settings) => { + const paths = [settings.path, ...settings.paths].map((path) => path.trim()).filter(Boolean); + return { settings, paths }; + }); + const pathMatch = settingsWithPaths.find(({ paths }) => { + return paths.includes(library.path) + || library.paths.some((path) => paths.includes(path)); + }); + + return pathMatch?.settings + ?? settingsWithPaths.find(({ settings }) => settings.name === library.name)?.settings; +} + +export function persistedLibraryForSettings(library: MediaLibrarySettings): MediaLibrary | undefined { + const configuredPaths = new Set([library.path, ...library.paths] + .map((path) => path.trim()) + .filter(Boolean)); + return state.libraries.find((candidate) => { + return configuredPaths.has(candidate.path) + || candidate.paths.some((path) => configuredPaths.has(path)); + }); +} + +export function canManuallyLinkMetadata(item?: MediaItemSummary): boolean { + return item?.item_type === 'movie' || item?.item_type === 'show'; +} + +export function backNavigationTarget(): { label: string; path: string } { + const hierarchy = state.selectedItem?.hierarchy ?? []; + const parent = hierarchy[hierarchy.length - 1]; + if (parent) { + return { + label: `Back to ${humanizeItemType(parent.item_type).toLowerCase()}`, + path: `/items/${parent.id}`, + }; + } + + const libraryId = state.selectedItem?.library_id; + return { + label: 'Back to library', + path: typeof libraryId === 'number' ? `/libraries/${libraryId}` : '/', + }; +} + +export function topLevelLibraryItems(): MediaItemSummary[] { + return state.libraryItems.filter((item) => item.parent_id == null); +} + +export function rootItemById(): Map { + return new Map(topLevelLibraryItems().map((item) => [item.id, item])); +} + +export function mediaItemsById(): Map { + return new Map(state.libraryItems.map((item) => [item.id, item])); +} + +export function homePreviewItemsById(): Map { + const items = [ + ...state.libraryItems, + ...(state.home?.shelves ?? []).flatMap((shelf) => shelf.items), + ...searchResultItems(), + ]; + + return new Map(items.map((item) => [item.id, item])); +} + +export function rootAncestorForItem(item: MediaItemSummary, itemsById: Map): MediaItemSummary { + let current = item; + + while (typeof current.parent_id === 'number') { + const parent = itemsById.get(current.parent_id); + if (!parent) { + break; + } + current = parent; + } + + return current; +} + +export function showPreviewItemForHighlight(item: MediaItemSummary): MediaItemSummary { + if (item.item_type !== 'season' && item.item_type !== 'episode') { + return item; + } + + const hierarchyShow = item.hierarchy?.find((ancestor) => ancestor.item_type === 'show'); + if (hierarchyShow) { + return hierarchyShow; + } + + const itemsById = homePreviewItemsById(); + let current = item; + while (typeof current.parent_id === 'number') { + const parent = itemsById.get(current.parent_id); + if (!parent) { + break; + } + if (parent.item_type === 'show') { + return parent; + } + current = parent; + } + + return item; +} + +export function categorySummaries(): Array<{ genre: string; count: number; items: MediaItemSummary[] }> { + const itemsById = mediaItemsById(); + const rootsById = rootItemById(); + const categories = new Map>(); + + state.libraryItems.forEach((item) => { + if (!item.genres.length) { + return; + } + + const rootItem = rootAncestorForItem(item, itemsById); + const root = rootsById.get(rootItem.id) ?? rootItem; + item.genres.forEach((genre) => { + const normalizedGenre = genre.trim(); + if (!normalizedGenre) { + return; + } + + if (!categories.has(normalizedGenre)) { + categories.set(normalizedGenre, new Map()); + } + categories.get(normalizedGenre)!.set(root.id, root); + }); + }); + + return [...categories.entries()] + .map(([genre, items]) => ({ genre, count: items.size, items: [...items.values()] })) + .sort((left, right) => right.count - left.count || left.genre.localeCompare(right.genre)); +} + +export function collectionSummaries(): MediaCollectionSummary[] { + return state.home?.collections ?? []; +} + +export function collectionForRoute(): MediaCollectionSummary | undefined { + const route = state.route; + if (route.page !== 'browse-detail' || route.kind !== 'collection') { + return undefined; + } + + return collectionSummaries().find((entry) => entry.id === route.key); +} + +export function itemsForCollection(collection: MediaCollectionSummary): MediaItemSummary[] { + const allowedIds = new Set(collection.item_ids); + return topLevelLibraryItems().filter((item) => allowedIds.has(item.id)); +} + +export function selectedItemRoot(): MediaItemSummary | undefined { + if (!state.selectedItem) { + return undefined; + } + + return state.selectedItem.hierarchy[0] ?? state.selectedItem; +} + +export function selectedItemCollectionRails(): Array<{ collection: MediaCollectionSummary; items: MediaItemSummary[] }> { + const root = selectedItemRoot(); + if (!root) { + return []; + } + + return collectionSummaries() + .filter((collection) => collection.item_ids.includes(root.id)) + .map((collection) => ({ + collection, + items: itemsForCollection(collection).filter((item) => item.id !== root.id), + })) + .filter((rail) => rail.items.length > 0); +} + +export function categoryForRoute(): { genre: string; count: number; items: MediaItemSummary[] } | undefined { + const route = state.route; + if (route.page !== 'browse-detail' || route.kind !== 'category') { + return undefined; + } + + return categorySummaries().find((entry) => entry.genre === route.key); +} + +export function browseItemsForRoute(): MediaItemSummary[] { + const route = state.route; + if (route.page !== 'browse-detail') { + return []; + } + + if (route.kind === 'collection') { + const collection = collectionForRoute(); + return collection ? itemsForCollection(collection) : []; + } + + if (route.kind === 'category') { + return categoryForRoute()?.items ?? []; + } + + return []; +} + +export function filteredTopLevelLibraryItems(): MediaItemSummary[] { + const items = topLevelLibraryItems(); + if (!state.browseFilter) { + return items; + } + + const allowedIds = new Set(state.browseFilter.itemIds); + return items.filter((item) => allowedIds.has(item.id)); +} + +export function searchResultItems(): MediaItemSummary[] { + return state.searchResults.flatMap((result) => result.result_type === 'item' ? [result.item] : []); +} + +export function searchResultCollections(): MediaCollectionSummary[] { + return state.searchResults.flatMap((result) => result.result_type === 'collection' ? [result.collection] : []); +} + +export function homeSearchPreview(): HomeFeaturePreview | undefined { + if (typeof state.homePreviewItemId === 'number') { + const item = searchResultItems().find((entry) => entry.id === state.homePreviewItemId); + if (item) { + return { kind: 'item', item: showPreviewItemForHighlight(item) }; + } + } + if (state.homePreviewCollectionId) { + const collection = searchResultCollections().find((entry) => entry.id === state.homePreviewCollectionId); + if (collection) { + return { kind: 'collection', collection }; + } + } + + for (const result of state.searchResults) { + if (result.result_type === 'item') { + return { kind: 'item', item: showPreviewItemForHighlight(result.item) }; + } + if (result.result_type === 'collection') { + return { kind: 'collection', collection: result.collection }; + } + } + return undefined; +} + +export function homeFeaturePreview(): HomeFeaturePreview | undefined { + if (state.route.page === 'browse-detail' && state.route.kind === 'collection') { + const collection = collectionForRoute(); + return collection ? { kind: 'collection', collection } : undefined; + } + + if (state.route.page === 'home' && state.searchQuery.trim() && state.searchResults.length) { + return homeSearchPreview(); + } + + if (state.route.page === 'home' && state.homeTab === 'collections') { + const collections = collectionSummaries(); + const collection = collections.find((entry) => entry.id === state.homePreviewCollectionId) ?? collections[0]; + return collection ? { kind: 'collection', collection } : undefined; + } + + const item = homePreviewItem(); + return item ? { kind: 'item', item } : undefined; +} + +export function homePreviewItem(): MediaItemSummary | undefined { + const items = homePreviewCandidates(); + if (!items.length) { + return undefined; + } + + return showPreviewItemForHighlight(items.find((item) => item.id === state.homePreviewItemId) ?? items[0]); +} + +export function homePreviewCandidates(): MediaItemSummary[] { + if (state.route.page === 'browse-detail') { + return browseItemsForRoute(); + } + + if (state.route.page === 'home' && state.searchQuery.trim() && state.searchResults.length) { + return searchResultItems(); + } + + switch (state.homeTab) { + case 'library': + return filteredTopLevelLibraryItems(); + case 'collections': { + return filteredTopLevelLibraryItems(); + } + case 'categories': { + const seen = new Set(); + const categoryItems = categorySummaries().flatMap((category) => category.items).filter((item) => { + if (seen.has(item.id)) { + return false; + } + seen.add(item.id); + return true; + }); + return categoryItems.length ? categoryItems : filteredTopLevelLibraryItems(); + } + default: { + const shelfItems = (state.home?.shelves ?? []).flatMap((shelf) => shelf.items); + return shelfItems.length ? shelfItems : filteredTopLevelLibraryItems(); + } + } +} + +export function pageBackdropUrlForItem(item: Pick | undefined): string | undefined { + return item?.backdrop_url + ? getArtworkUrl(item.id, 'backdrop', item.artwork_updated_at) + : undefined; +} + +export function pageBackdropUrlForCollection(collection: Pick | undefined): string | undefined { + const artworkUrl = collection?.backdrop_url ?? collection?.artwork_url; + return artworkUrl ? resolveApiUrl(artworkUrl) : undefined; +} + +export function pageBackdropUrlForHomePreview(preview: HomeFeaturePreview | undefined): string | undefined { + if (!preview) { + return undefined; + } + + return preview.kind === 'collection' + ? pageBackdropUrlForCollection(preview.collection) + : pageBackdropUrlForItem(preview.item); +} diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts new file mode 100644 index 00000000..e89276ea --- /dev/null +++ b/crates/client-web/src/app/settingsView.ts @@ -0,0 +1,831 @@ +/** Renders settings sections and converts settings forms into API payloads. */ +import type { MediaLibrary, MediaLibrarySettings, MetadataProviderSettings, MetadataProviderStatus, ScheduledTaskId, SettingsSnapshot } from '../api'; +import { escapeHtml } from './format'; +import { formDataString, formDataStrings, joinPaths, normalizedMetadataLanguages, parseBoundedInteger, parsePathsInput } from './formUtils'; +import { hasActiveLibraryScan, libraryRefreshProgress } from './activities'; +import { renderLogViewer, renderMetadataDashboard, renderSystemActivitiesPanel } from './dashboardView'; +import { renderUserManagement } from './auth'; +import { providerDisplayName, libraryProviderOptions } from './providers'; +import { persistedLibraryForSettings } from './selectors'; +import { state } from './state'; +import type { SettingsSection } from './types'; +import { renderButtonContent, renderIcon, renderPageNavbar } from './ui'; + +export function activeSettingsSection(): SettingsSection { + return state.route.page === 'settings' ? state.route.section ?? 'general' : 'general'; +} + +export function renderSettingsSectionNav(): string { + const activeSection = activeSettingsSection(); + const sections: Array<{ id: SettingsSection; label: string; path: string }> = [ + { id: 'general', label: 'General', path: '/settings' }, + { id: 'libraries', label: 'Libraries', path: '/settings/libraries' }, + { id: 'providers', label: 'Providers', path: '/settings/providers' }, + { id: 'scheduled', label: 'Scheduled', path: '/settings/scheduled' }, + { id: 'dashboard', label: 'Dashboard', path: '/settings/dashboard' }, + { id: 'logs', label: 'Logs', path: '/settings/logs' }, + ]; + + return ` + + `; +} + +export const metadataLanguageOptions: Array<{ value: string; label: string }> = [ + { value: 'en-US', label: 'English (United States)' }, + { value: 'en-GB', label: 'English (United Kingdom)' }, + { value: 'es-ES', label: 'Spanish (Spain)' }, + { value: 'fr-FR', label: 'French (France)' }, + { value: 'de-DE', label: 'German (Germany)' }, + { value: 'it-IT', label: 'Italian (Italy)' }, + { value: 'ja-JP', label: 'Japanese (Japan)' }, + { value: 'pt-BR', label: 'Portuguese (Brazil)' }, +]; + +export function metadataLanguageSelect(name: string, selectedLanguages?: string[]): string { + const selected = normalizedMetadataLanguages(selectedLanguages); + return ` + + `; +} + +export function metadataLanguageModeSelect(name: string, selectedMode: 'auto' | 'manual' = 'auto'): string { + return ` + + `; +} + +export function userPermissionSelect(name: string, allowedUserIds?: number[]): string { + const selected = new Set(allowedUserIds ?? []); + return ` + + `; +} + +function metadataProviderSortIndex(selectedProviders: string[], providerId: string): number { + const selectedIndex = selectedProviders.indexOf(providerId); + return selectedIndex < 0 ? Number.MAX_SAFE_INTEGER : selectedIndex; +} + +function metadataProviderRoleOrder(provider: MetadataProviderStatus): number { + return provider.role === 'primary' ? 0 : 1; +} + +function compareMetadataProviderOptions(selectedProviders: string[]): (left: MetadataProviderStatus, right: MetadataProviderStatus) => number { + return (left, right) => { + return metadataProviderRoleOrder(left) - metadataProviderRoleOrder(right) + || metadataProviderSortIndex(selectedProviders, left.id) - metadataProviderSortIndex(selectedProviders, right.id) + || left.display_name.localeCompare(right.display_name); + }; +} + +function renderPrimaryProviderMoveButtons(label: string, isSecondary: boolean): string { + return isSecondary + ? '' + : ` + + + `; +} + +function renderMetadataProviderOption( + prefix: string, + provider: MetadataProviderStatus, + selected: Set, + primaryPriority: number, +): string { + const providerId = provider.id; + const label = provider.display_name; + const isSecondary = provider.role === 'secondary'; + const secondaryAvailable = isSecondary + ? provider.extends_provider_ids.some((primaryProviderId) => selected.has(primaryProviderId)) + : true; + const checked = selected.has(providerId) && secondaryAvailable; + const providerPriorityLabel = isSecondary ? 'Secondary' : `Priority ${primaryPriority}`; + + return ` + + `; +} + +export function metadataProviderCheckboxes(prefix: string, selectedProviders: string[], libraryKind?: string): string { + const providers = libraryProviderOptions(libraryKind) + .sort(compareMetadataProviderOptions(selectedProviders)); + const selected = new Set(selectedProviders); + let primaryPriority = 0; + + return ` + + `; +} + +function renderPersistedLibraryTags(persistedLibrary: MediaLibrary | undefined, scanPending: boolean): string { + if (!persistedLibrary) { + return ''; + } + + const missingFiles = persistedLibrary.missing_files ?? 0; + const missingItems = persistedLibrary.missing_items ?? 0; + const hasMissingItems = missingFiles > 0 || missingItems > 0; + const scanPendingTag = scanPending ? 'Scanning catalog' : ''; + const missingItemsTagClass = hasMissingItems ? 'warning' : 'success'; + const missingItemsLabel = hasMissingItems ? `${missingItems} missing items` : 'No missing items'; + const missingFilesLabel = `${missingFiles} missing files`; + const missingFilesTag = missingFiles > 0 + ? `${escapeHtml(missingFilesLabel)}` + : ''; + + return `
+ ${scanPendingTag} + ${escapeHtml(missingItemsLabel)} + ${missingFilesTag} +
`; +} + +function renderPersistedLibraryActions(persistedLibrary: MediaLibrary | undefined, refreshPending: boolean, scanPending: boolean): string { + if (!persistedLibrary) { + return ''; + } + + const hasMissingItems = (persistedLibrary.missing_files ?? 0) > 0 || (persistedLibrary.missing_items ?? 0) > 0; + const refreshLabel = refreshPending ? 'Refreshing metadata' : 'Refresh metadata'; + const scanButtonDisabled = scanPending ? 'disabled' : ''; + const scanButtonLabel = scanPending ? 'Scanning' : 'Scan now'; + const refreshButtonDisabled = refreshPending ? 'disabled' : ''; + const deleteMissingDisabled = hasMissingItems ? '' : 'disabled'; + + return ` + + + + `; +} + +function renderExistingLibrarySettingsCard(library: MediaLibrarySettings, index: number): string { + const persistedLibrary = persistedLibraryForSettings(library); + const refreshPending = persistedLibrary ? Boolean(libraryRefreshProgress(persistedLibrary)) : false; + const scanPending = persistedLibrary ? hasActiveLibraryScan(persistedLibrary.id) : hasActiveLibraryScan(); + const persistedLibraryTags = renderPersistedLibraryTags(persistedLibrary, scanPending); + const persistedLibraryActions = renderPersistedLibraryActions(persistedLibrary, refreshPending, scanPending); + + return ` +
+
+
+

Library ${index + 1}

+

${escapeHtml(library.name || `Library ${index + 1}`)}

+ ${persistedLibraryTags} +
+
+ ${persistedLibraryActions} + +
+
+
+ + + +
+ +
+ +
+
+ + +
+
+ +
+
+ Metadata sources + ${metadataProviderCheckboxes(`existing_library_metadata_provider_${index}`, library.metadata_providers, library.kind)} +
+
+ `; +} + +export function renderExistingLibrariesSettings(settings: SettingsSnapshot): string { + if (!settings.media.libraries.length) { + return '
No libraries are configured yet.
'; + } + + return settings.media.libraries + .map((library, index) => renderExistingLibrarySettingsCard(library, index)) + .join(''); +} + +export function scheduledWeekdayLabel(weekday: string): string { + return weekday.slice(0, 3).toUpperCase(); +} + +export function renderScheduledTaskRunButton(taskId: ScheduledTaskId): string { + return ``; +} + +const SCHEDULED_TASK_WEEKDAYS: SettingsSnapshot['scheduled_tasks']['window']['weekdays'] = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; + +function renderScheduledStatusTag(enabled: boolean, enabledLabel: string, disabledLabel: string, enabledClass = 'success'): string { + return `${enabled ? enabledLabel : disabledLabel}`; +} + +function renderScheduledWeekdayToggles(selectedWeekdays: Set): string { + return ` +
+ ${SCHEDULED_TASK_WEEKDAYS.map((weekday) => ` + + `).join('')} +
+ `; +} + +export function renderScheduledTasksPage(settings: SettingsSnapshot): string { + const scheduled = settings.scheduled_tasks; + const selectedWeekdays = new Set(scheduled.window.weekdays); + const trashCleanupDays = scheduled.trash_cleanup.missing_item_auto_delete_days ?? 30; + + return ` +
+
+
+
+

Scheduled tasks

+
+
+
+
+

Runner

+

Task window

+
+ ${renderScheduledStatusTag(scheduled.enabled, 'Enabled', 'Disabled')} +
+
+ +
+
+ + +
+
+ Run days + ${renderScheduledWeekdayToggles(selectedWeekdays)} +
+
+ +
+
+
+
+

Task

+

Metadata refresh

+
+
+ ${renderScheduledStatusTag(scheduled.metadata_refresh.enabled, 'Scheduled', 'Manual')} + ${renderScheduledTaskRunButton('metadata_refresh')} +
+
+
+ +
+
+ +
+
+ +
+
+
+

Task

+

Trash cleanup

+
+
+ ${renderScheduledStatusTag(scheduled.trash_cleanup.enabled, 'Scheduled', 'Manual', 'warning')} + ${renderScheduledTaskRunButton('trash_cleanup')} +
+
+
+ +
+
+ + +
+
+ +
+
+
+

Task

+

Database maintenance

+
+
+ ${renderScheduledStatusTag(scheduled.database_maintenance.enabled, 'Scheduled', 'Manual')} + ${renderScheduledTaskRunButton('database_maintenance')} +
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+ `; +} + +export function libraryKindOptions(selectedKind: string): string { + return [ + ['movies', 'Movies'], + ['shows', 'Shows'], + ['music', 'Music'], + ['photos', 'Photos'], + ['books', 'Books'], + ['home_videos', 'Home videos'], + ] + .map(([value, label]) => ``) + .join(''); +} + +export function libraryScannerOptions(selectedScanner: string): string { + return [ + ['auto', 'Auto'], + ['directory', 'Directory'], + ['movies', 'Movies'], + ['shows', 'Shows'], + ['music', 'Music'], + ['photos', 'Photos'], + ['books', 'Books'], + ] + .map(([value, label]) => ``) + .join(''); +} + +export function renderProviderSettingsCard(provider: MetadataProviderSettings): string { + const label = providerDisplayName(provider.id); + const status = state.metadataProviders.find((entry) => entry.id === provider.id); + const logoUrl = status?.logo_dark_url ?? status?.logo_light_url; + const showApiKey = Boolean(status?.requires_api_key); + const apiKeyConfigured = Boolean(provider.api_key_configured || provider.api_key_secret_ref || provider.api_key); + const showRequestSettings = provider.id !== 'local_nfo'; + const logoMarkup = logoUrl ? `` : ''; + const providerRoleLabel = status?.role === 'secondary' ? 'Secondary' : 'Primary'; + const providerRoleTag = status?.role ? `${escapeHtml(providerRoleLabel)}` : ''; + const providerDescription = status?.description ? `

${escapeHtml(status.description)}

` : ''; + const providerAttribution = status?.attribution_text ? `

${escapeHtml(status.attribution_text)}

` : ''; + const apiKeyPlaceholder = apiKeyConfigured ? 'Saved' : ''; + const apiKeyField = showApiKey + ? `` + : ''; + const clearApiKeyField = showApiKey && apiKeyConfigured + ? `` + : ''; + const requestSettingsFields = showRequestSettings + ? ` + + + + ` + : ''; + const providerSettingsFields = showApiKey || showRequestSettings + ? `
+ ${apiKeyField} + ${clearApiKeyField} + ${requestSettingsFields} +
` + : '

This provider does not require provider-specific settings.

'; + return ` +
+
+
+ ${logoMarkup} +
+

Provider

+

${escapeHtml(label)}

+
+
+ ${providerRoleTag} +
+ ${providerDescription} + ${providerAttribution} + ${providerSettingsFields} +
+ `; +} + +export function renderProviderSettingsPage(settings: SettingsSnapshot): string { + return ` +
+
+
+
+

Metadata providers

+
+

Provider credentials and retry behavior are configured here. Metadata languages are selected per library.

+
+ ${settings.metadata.providers.map(renderProviderSettingsCard).join('')} +
+
+ +

Provider response cache is kept for 24 hours by default.

+
+
+
+ +
+
+
+ `; +} + +function renderGeneralSettingsPage(settings: SettingsSnapshot): string { + const useHttpsChecked = settings.server.use_https ? 'checked' : ''; + const useCustomCertsChecked = settings.server.use_custom_certs ? 'checked' : ''; + return ` +
+
+
+

Server

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+

FFmpeg

+
+ + +
+
+ +
+

Metadata providers

+

Provider credentials and refresh behavior are configured on their own settings page.

+ +
+ +
+ + +
+
+ + ${renderUserManagement()} +
+ `; +} + +function renderLibrarySettingsPage(settings: SettingsSnapshot): string { + return ` +
+
+
+
+

Libraries

+
+

Each logical library can now contain multiple folders. Enter one folder per line.

+
+ ${renderExistingLibrariesSettings(settings)} +
+
+
+ +
+
+ +
+
+

Add library

+ + +
+ + + +
+
+ + +
+
+ +
+
+ Metadata sources +
${metadataProviderCheckboxes('library_metadata_provider', ['tmdb'])}
+
+
+ +
+
+ `; +} + +function renderSettingsSectionContent(section: SettingsSection, settings: SettingsSnapshot): string { + if (section === 'general') { + return renderGeneralSettingsPage(settings); + } + if (section === 'providers') { + return renderProviderSettingsPage(settings); + } + if (section === 'scheduled') { + return renderScheduledTasksPage(settings); + } + if (section === 'libraries') { + return renderLibrarySettingsPage(settings); + } + if (section === 'dashboard') { + return ` +
${renderMetadataDashboard()}
+
${renderSystemActivitiesPanel()}
+ `; + } + if (section === 'logs') { + return '
' + renderLogViewer() + '
'; + } + return ''; +} + +export function renderSettingsPage(): string { + const settings = state.settingsResponse?.settings; + if (!settings) { + return '
Settings are still loading…
'; + } + + const section = activeSettingsSection(); + const settingsContent = renderSettingsSectionContent(section, settings); + + return ` + ${renderPageNavbar( + 'Settings', + 'Program configuration', + `Saved to ${state.settingsResponse?.settings_path ?? ''}`, + )} + ${renderSettingsSectionNav()} + ${settingsContent} + `; +} + +export function buildSettingsFromForm(formData: FormData): SettingsSnapshot | undefined { + const current = state.settingsResponse?.settings; + if (!current) { + return undefined; + } + const settingsSection = activeSettingsSection(); + let metadataRefreshIntervalDays = current.metadata.refresh_interval_days; + if (formData.has('metadata_refresh_interval_days')) { + const refreshIntervalValue = formDataString(formData.get('metadata_refresh_interval_days')); + if (refreshIntervalValue === 'never') { + metadataRefreshIntervalDays = null; + } else { + metadataRefreshIntervalDays = Number(formDataString( + formData.get('metadata_refresh_interval_days'), + String(current.metadata.refresh_interval_days ?? 30), + )); + } + } + + return { + general: { + data_dir: formDataString(formData.get('data_dir'), current.general.data_dir), + }, + media: { + missing_item_auto_delete_days: null, + libraries: current.media.libraries.map((library, index) => { + const pathsField = `existing_library_paths_${index}`; + if (!formData.has(pathsField)) { + return library; + } + + const paths = parsePathsInput(formData.get(pathsField)); + const providerField = `existing_library_metadata_provider_${index}`; + return { + name: formDataString(formData.get(`existing_library_name_${index}`), library.name), + path: paths[0] ?? library.path, + paths, + recursive: formData.get(`existing_library_recursive_${index}`) === 'on', + kind: formDataString(formData.get(`existing_library_kind_${index}`), library.kind), + scanner: formDataString(formData.get(`existing_library_scanner_${index}`), library.scanner ?? 'auto'), + metadata_providers: formDataStrings(formData.getAll(providerField)), + metadata_language_mode: formDataString(formData.get(`existing_library_metadata_language_mode_${index}`), library.metadata_language_mode ?? 'auto') === 'manual' + ? 'manual' + : 'auto', + metadata_languages: formData.has(`existing_library_metadata_language_${index}`) + ? normalizedMetadataLanguages(formDataStrings(formData.getAll(`existing_library_metadata_language_${index}`))) + : normalizedMetadataLanguages(library.metadata_languages), + allowed_user_ids: formData.has(`existing_library_allowed_user_${index}`) + ? formData.getAll(`existing_library_allowed_user_${index}`) + .map(Number) + .filter((value) => Number.isFinite(value) && value > 0) + : library.allowed_user_ids, + }; + }), + }, + metadata: { + refresh_interval_days: metadataRefreshIntervalDays, + providers: current.metadata.providers.map((provider) => { + const prefix = provider.id; + if ( + !formData.has(`${prefix}_api_key`) + && !formData.has(`${prefix}_clear_api_key`) + && !formData.has(`${prefix}_rate_limit_per_second`) + && !formData.has(`${prefix}_retry_attempts`) + && !formData.has(`${prefix}_retry_backoff_ms`) + ) { + return provider; + } + + const submittedApiKey = formData.has(`${prefix}_api_key`) + ? formDataString(formData.get(`${prefix}_api_key`)).trim() + : undefined; + const clearApiKey = formData.get(`${prefix}_clear_api_key`) === 'on'; + + return { + ...provider, + api_key: submittedApiKey && !clearApiKey ? submittedApiKey : null, + clear_api_key: clearApiKey, + rate_limit_per_second: Math.max(1, Number(formData.get(`${prefix}_rate_limit_per_second`) ?? provider.rate_limit_per_second)), + retry_attempts: Math.max(0, Number(formData.get(`${prefix}_retry_attempts`) ?? provider.retry_attempts)), + retry_backoff_ms: Math.max(1, Number(formData.get(`${prefix}_retry_backoff_ms`) ?? provider.retry_backoff_ms)), + }; + }), + }, + server: { + use_https: settingsSection === 'general' ? formData.get('use_https') === 'on' : current.server.use_https, + address: formDataString(formData.get('address'), current.server.address), + port: Number(formData.get('port') ?? current.server.port), + cert_path: formDataString(formData.get('cert_path'), current.server.cert_path), + key_path: formDataString(formData.get('key_path'), current.server.key_path), + use_custom_certs: settingsSection === 'general' + ? formData.get('use_custom_certs') === 'on' + : current.server.use_custom_certs, + }, + ffmpeg: { + ffmpeg_path: formDataString(formData.get('ffmpeg_path'), current.ffmpeg.ffmpeg_path), + ffprobe_path: formDataString(formData.get('ffprobe_path'), current.ffmpeg.ffprobe_path), + }, + scheduled_tasks: parseScheduledTasksSettings(formData, current), + }; +} + +export function parseScheduledTasksSettings(formData: FormData, current: SettingsSnapshot): SettingsSnapshot['scheduled_tasks'] { + if (!formData.has('scheduled_window_start_time')) { + return current.scheduled_tasks; + } + + const weekdays = formData.getAll('scheduled_window_weekday') + .filter((value): value is string => typeof value === 'string') + .filter((value): value is SettingsSnapshot['scheduled_tasks']['window']['weekdays'][number] => ( + ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].includes(value) + )); + + return { + enabled: formData.get('scheduled_tasks_enabled') === 'on', + window: { + start_time: formDataString(formData.get('scheduled_window_start_time'), current.scheduled_tasks.window.start_time), + stop_time: formDataString(formData.get('scheduled_window_stop_time'), current.scheduled_tasks.window.stop_time), + weekdays: weekdays.length ? weekdays : current.scheduled_tasks.window.weekdays, + }, + metadata_refresh: { + enabled: formData.get('scheduled_metadata_refresh_enabled') === 'on', + }, + trash_cleanup: { + enabled: formData.get('scheduled_trash_cleanup_enabled') === 'on', + missing_item_auto_delete_days: parseBoundedInteger( + formData.get('scheduled_trash_cleanup_days'), + current.scheduled_tasks.trash_cleanup.missing_item_auto_delete_days ?? 30, + 1, + 3650, + ), + interval_days: parseBoundedInteger( + formData.get('scheduled_trash_cleanup_interval_days'), + current.scheduled_tasks.trash_cleanup.interval_days, + 1, + 365, + ), + }, + database_maintenance: { + enabled: formData.get('scheduled_database_maintenance_enabled') === 'on', + interval_days: parseBoundedInteger( + formData.get('scheduled_database_maintenance_interval_days'), + current.scheduled_tasks.database_maintenance.interval_days, + 1, + 365, + ), + }, + }; +} diff --git a/crates/client-web/src/app/spinners.ts b/crates/client-web/src/app/spinners.ts new file mode 100644 index 00000000..00b228fe --- /dev/null +++ b/crates/client-web/src/app/spinners.ts @@ -0,0 +1,26 @@ +let spinnerVisibilityObserver: IntersectionObserver | undefined; + +function visibleSpinnerObserver(): IntersectionObserver | undefined { + if (!('IntersectionObserver' in globalThis)) { + return undefined; + } + spinnerVisibilityObserver ??= new IntersectionObserver((entries) => { + entries.forEach((entry) => { + entry.target.classList.toggle('is-spinner-visible', entry.isIntersecting); + }); + }, { rootMargin: '120px' }); + return spinnerVisibilityObserver; +} + +/** Syncs lazy spinner visibility classes with the viewport. */ +export function syncVisibleSpinners(): void { + const spinners = Array.from(document.querySelectorAll('.loading-spinner:not(.player-loading-spinner)')); + const observer = visibleSpinnerObserver(); + if (!observer) { + spinners.forEach((spinner) => spinner.classList.add('is-spinner-visible')); + return; + } + + observer.disconnect(); + spinners.forEach((spinner) => observer.observe(spinner)); +} diff --git a/crates/client-web/src/app/state.ts b/crates/client-web/src/app/state.ts new file mode 100644 index 00000000..37484fd6 --- /dev/null +++ b/crates/client-web/src/app/state.ts @@ -0,0 +1,51 @@ +import { getApiMode, getStoredApiBase } from '../api'; +import { defaultHomeTab, parseRoute } from './routes'; +import type { AppState } from './types'; + +/** Shared mutable application state for the browser client. */ +export const state: AppState = { + apiBase: getStoredApiBase(), + apiMode: getApiMode(), + route: parseRoute(), + users: [], + libraries: [], + libraryItems: [], + libraryItemsLoading: false, + searchResults: [], + homePreviewItemId: undefined, + homePreviewCollectionId: undefined, + metadataProviders: [], + systemActivities: [], + dashboardItems: [], + metadataSearchResults: [], + searchQuery: '', + metadataSearchQuery: '', + metadataSearchYear: '', + metadataSearchLanguage: 'en', + metadataSearchProviders: [], + showFullSearchResults: false, + homeTab: defaultHomeTab(parseRoute()), + isLoading: true, + hasDeferredAutoRefreshRender: false, + isPlayerOpen: false, + activePlaybackItem: undefined, + activePlaybackStartMs: 0, + activeAudioStreamIndex: undefined, + isAudioTrackMenuOpen: false, + isTrailerMenuOpen: false, + activeTrailer: undefined, + expandedTextKeys: new Set(), + metadataDashboardFilters: { + libraryId: '', + itemType: '', + refreshState: '', + search: '', + }, + logFilters: { + level: '', + module: '', + search: '', + since: '', + until: '', + }, +}; diff --git a/crates/client-web/src/app/types.ts b/crates/client-web/src/app/types.ts new file mode 100644 index 00000000..60ba8a42 --- /dev/null +++ b/crates/client-web/src/app/types.ts @@ -0,0 +1,209 @@ +import type { + ApiMode, + AppBootstrapResponse, + BootstrapUser, + ItemMetadataResponse, + LogEntriesResponse, + MediaHome, + MediaItemDetail, + MediaItemSummary, + MediaLibrary, + MediaSearchResult, + MetadataPersonResponse, + MetadataProviderStatus, + MetadataSearchResult, + PlaybackDecision, + PlaybackSession, + ServerCapabilities, + SettingsResponse, + SystemActivity, +} from '../api'; + +/** Client-side routes supported by the single-page web UI. */ +export type AppRoute = + | { page: 'home'; libraryId?: number } + | { page: 'browse-detail'; kind: 'category' | 'collection' | 'playlist'; key: string; libraryId?: number } + | { page: 'item'; itemId: number } + | { page: 'person'; personId: number } + | { page: 'settings'; section?: SettingsSection }; + +/** Top-level tabs shown on the home browsing surface. */ +export type HomeBrowseTab = 'recommended' | 'library' | 'collections' | 'playlists' | 'categories'; + +/** Settings subsections addressable from navigation and direct URLs. */ +export type SettingsSection = 'general' | 'libraries' | 'providers' | 'scheduled' | 'dashboard' | 'logs'; + +/** Describes a resolved browse filter used by category, collection, and playlist detail pages. */ +export interface BrowseFilter { + kind: 'category' | 'collection' | 'playlist'; + label: string; + itemIds: number[]; + overview?: string; + artworkUrl?: string; +} + +/** Playback-ready trailer metadata used by the overlay player. */ +export interface TrailerOption { + title: string; + url: string; + label?: string; + titleSuffix?: string; +} + +/** Theme-song source variants supported by the web UI. */ +export type ThemeSongSource = + | { kind: 'audio'; src: string; title: string } + | { kind: 'youtube'; src: string; title: string; videoId: string }; + +/** Minimal YouTube iframe player contract used by the client. */ +export interface YouTubePlayer { + loadVideoById(videoId: string): void; + playVideo(): void; + pauseVideo(): void; + seekTo(seconds: number, allowSeekAhead: boolean): void; + getCurrentTime(): number; + getDuration(): number; + getPlayerState(): number; + setVolume(volume: number): void; + getVolume(): number; + mute(): void; + unMute(): void; + isMuted(): boolean; + setPlaybackQuality(suggestedQuality: string): void; + destroy(): void; +} + +/** Browser global exposed after loading the YouTube iframe API script. */ +export interface YouTubeIframeApi { + Player: new ( + elementId: string, + options: { + height: string; + width: string; + videoId?: string; + playerVars?: Record; + events?: { + onReady?: (event: { target: YouTubePlayer }) => void; + onStateChange?: () => void; + onError?: (event: { data: number }) => void; + }; + }, + ) => YouTubePlayer; +} + +declare global { + var YT: YouTubeIframeApi | undefined; + var onYouTubeIframeAPIReady: (() => void) | undefined; +} + +/** Mutable state for the browser client between render passes. */ +export interface AppState { + apiBase: string; + apiMode: ApiMode; + route: AppRoute; + bootstrap?: AppBootstrapResponse; + users: BootstrapUser[]; + capabilities?: ServerCapabilities; + libraries: MediaLibrary[]; + home?: MediaHome; + libraryItems: MediaItemSummary[]; + libraryItemsLoading: boolean; + searchResults: MediaSearchResult[]; + homePreviewItemId?: number; + homePreviewCollectionId?: string; + metadataProviders: MetadataProviderStatus[]; + systemActivities: SystemActivity[]; + dashboardItems: MediaItemSummary[]; + settingsResponse?: SettingsResponse; + logsResponse?: LogEntriesResponse; + selectedItem?: MediaItemDetail; + selectedItemMetadata?: ItemMetadataResponse; + selectedPerson?: MetadataPersonResponse; + selectedPlayback?: PlaybackDecision; + metadataSearchResults: MetadataSearchResult[]; + searchQuery: string; + metadataSearchQuery: string; + metadataSearchYear: string; + metadataSearchLanguage: string; + metadataSearchProviders: string[]; + showFullSearchResults: boolean; + homeTab: HomeBrowseTab; + browseFilter?: BrowseFilter; + isLoading: boolean; + isPlayerOpen: boolean; + activePlaybackItem?: MediaItemDetail; + activePlaybackSession?: PlaybackSession; + activePlaybackStartMs: number; + activeAudioStreamIndex?: number; + isAudioTrackMenuOpen: boolean; + isTrailerMenuOpen: boolean; + activeTrailer?: TrailerOption; + expandedTextKeys: Set; + error?: string; + hasDeferredAutoRefreshRender: boolean; + metadataDashboardFilters: { + libraryId: string; + itemType: string; + refreshState: string; + search: string; + }; + logFilters: { + level: string; + module: string; + search: string; + since: string; + until: string; + }; +} + +/** Lucide icon names intentionally allowed by the web UI renderer. */ +export type AppIconName = + | 'arrow-left' + | 'arrow-right' + | 'book' + | 'chevron-left' + | 'chevron-right' + | 'circle-check' + | 'clapperboard' + | 'database-zap' + | 'film' + | 'folder-sync' + | 'house' + | 'image' + | 'languages' + | 'layers' + | 'layout-grid' + | 'link-2' + | 'log-in' + | 'log-out' + | 'music' + | 'maximize' + | 'pause' + | 'picture-in-picture' + | 'play' + | 'plus' + | 'refresh-cw' + | 'save' + | 'search' + | 'settings' + | 'skip-back' + | 'skip-forward' + | 'trash-2' + | 'tv' + | 'triangle-alert' + | 'user-plus' + | 'volume-2' + | 'volume-x' + | 'x'; + +/** Grouped credit data used by the person detail renderer. */ +export interface PersonSeasonCreditGroup { + season: MediaItemSummary; + episodes: MediaItemSummary[]; +} + +/** Top-level person credit group keyed by root media item. */ +export interface PersonCreditGroup { + root: MediaItemSummary; + seasons: PersonSeasonCreditGroup[]; +} diff --git a/crates/client-web/src/app/ui.ts b/crates/client-web/src/app/ui.ts new file mode 100644 index 00000000..1a63d3e9 --- /dev/null +++ b/crates/client-web/src/app/ui.ts @@ -0,0 +1,162 @@ +import type { BootstrapUser, MediaItemSummary } from '../api'; +import { resolveApiUrl } from '../api'; +import { COLLAPSIBLE_TEXT_LENGTH, COLLAPSIBLE_TEXT_LINE_COUNT } from './constants'; +import { escapeHtml, formatDuration } from './format'; +import { state } from './state'; +import type { AppIconName } from './types'; + +export function renderCollapsibleText(text: string, key: string, className = 'hero-description'): string { + const normalized = text.trim(); + const lineCount = normalized.split(/\r\n|\r|\n/).length; + const shouldCollapse = normalized.length > COLLAPSIBLE_TEXT_LENGTH || lineCount > COLLAPSIBLE_TEXT_LINE_COUNT; + const isExpanded = state.expandedTextKeys.has(key); + const stateClass = shouldCollapse && !isExpanded ? 'is-collapsed' : ''; + const toggleExpanded = isExpanded ? 'true' : 'false'; + const toggleLabel = isExpanded ? 'show less' : '... see more'; + const toggle = shouldCollapse + ? `` + : ''; + + return ` +
${escapeHtml(normalized)}
+ ${toggle} + `; +} + +export function setButtonBusy(button: HTMLButtonElement | null | undefined, busy: boolean): void { + if (!button) { + return; + } + button.disabled = busy; + button.classList.toggle('is-busy', busy); + button.setAttribute('aria-busy', busy ? 'true' : 'false'); +} + +export function selectedLibraryIcon(kind?: string): AppIconName { + switch (kind) { + case 'mixed': + return 'layout-grid'; + case 'movies': + return 'clapperboard'; + case 'shows': + return 'tv'; + case 'music': + return 'music'; + case 'photos': + return 'image'; + case 'books': + return 'book'; + case 'home_videos': + return 'film'; + default: + return 'layout-grid'; + } +} + +export function renderIcon(iconName: AppIconName, className = 'rail-icon'): string { + return ``; +} + +export function renderButtonContent(label: string, iconName?: AppIconName, iconPosition: 'start' | 'end' = 'start'): string { + if (!iconName) { + return escapeHtml(label); + } + + return ` + + ${renderIcon(iconName, 'button-icon')} + ${escapeHtml(label)} + + `; +} + +export function renderUserAvatar(user: BootstrapUser, className = ''): string { + const imageUrl = user.profile_image_url ? resolveApiUrl(user.profile_image_url) : undefined; + const initial = user.username.trim().charAt(0).toUpperCase() || '?'; + return ` + + ${imageUrl + ? `` + : `${escapeHtml(initial)}`} + + `; +} + +export function humanizeItemType(itemType: string): string { + switch (itemType) { + case 'show': + return 'Show'; + case 'season': + return 'Season'; + case 'episode': + return 'Episode'; + case 'movie': + return 'Movie'; + case 'track': + return 'Track'; + case 'photo': + return 'Photo'; + case 'book': + return 'Book'; + default: + return itemType.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()); + } +} + +export function formatChildCount(item: MediaItemSummary): string { + if (!item.child_count) { + return formatDuration(item.duration_ms); + } + + if (item.item_type === 'show') { + const seasonCount = item.available_season_count ?? item.child_count; + return `${seasonCount} season${seasonCount === 1 ? '' : 's'}`; + } + + if (item.item_type === 'season') { + return `${item.child_count} episode${item.child_count === 1 ? '' : 's'}`; + } + + return `${item.child_count} item${item.child_count === 1 ? '' : 's'}`; +} + +export function libraryStatusLabel(status: string): string { + switch (status) { + case 'never_scanned': + return 'Pending first scan'; + case 'available': + return 'Ready'; + case 'missing_path': + return 'Missing path'; + case 'not_directory': + return 'Invalid folder'; + case 'unreadable': + return 'Unreadable'; + case 'empty_path': + return 'No folder'; + default: + return status.replace(/_/g, ' '); + } +} + +export function renderPageNavbar(eyebrow: string, title: string, description: string, actions = ''): string { + return ` +
+
+

${escapeHtml(eyebrow)}

+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
+ ${actions ? `
${actions}
` : ''} +
+ `; +} + +export function subtitleLanguage(trackLabel: string): string { + const normalized = trackLabel.trim().toLowerCase(); + if (/^[a-z]{2,3}$/.test(normalized)) { + return normalized; + } + + return 'en'; +} diff --git a/crates/client-web/src/app/youtube.ts b/crates/client-web/src/app/youtube.ts new file mode 100644 index 00000000..2f5208f1 --- /dev/null +++ b/crates/client-web/src/app/youtube.ts @@ -0,0 +1,115 @@ +import type { YouTubeIframeApi } from './types'; + +let youtubeIframeApiPromise: Promise | undefined; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; +const PROTOCOL_RELATIVE_YOUTUBE_PREFIXES = [ + '//youtube.com/', + '//www.youtube.com/', + '//youtu.be/', + '//youtube-nocookie.com/', + '//www.youtube-nocookie.com/', +] as const; +const BARE_YOUTUBE_PREFIXES = [ + 'youtube.com/', + 'www.youtube.com/', + 'youtu.be/', + 'youtube-nocookie.com/', + 'www.youtube-nocookie.com/', +] as const; +const YOUTUBE_VIDEO_PATH_KINDS = new Set(['embed', 'shorts', 'live']); + +function validYouTubeVideoId(videoId: string | undefined): string | undefined { + return YOUTUBE_VIDEO_ID_PATTERN.test(videoId ?? '') ? videoId : undefined; +} + +function normalizedYouTubeParseTarget(url: string): string { + if (PROTOCOL_RELATIVE_YOUTUBE_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return `https:${url}`; + } + + if (BARE_YOUTUBE_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return `https://${url}`; + } + + return url; +} + +function isYouTubeHost(host: string): boolean { + return host === 'youtube.com' + || host.endsWith('.youtube.com') + || host === 'youtube-nocookie.com' + || host.endsWith('.youtube-nocookie.com'); +} + +function extractVideoIdFromParsedUrl(parsed: URL): string | undefined { + const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + if (host === 'youtu.be') { + return validYouTubeVideoId(parsed.pathname.split('/').find((segment) => segment.length > 0)); + } + + if (!isYouTubeHost(host)) { + return undefined; + } + + if (parsed.pathname.startsWith('/watch')) { + return validYouTubeVideoId(parsed.searchParams.get('v')?.trim()); + } + + const [kind, videoId] = parsed.pathname.split('/').filter(Boolean); + return YOUTUBE_VIDEO_PATH_KINDS.has(kind ?? '') ? validYouTubeVideoId(videoId) : undefined; +} + +/** Extracts the canonical 11-character video ID from common YouTube URL formats. */ +export function extractYouTubeVideoId(url: string): string | undefined { + const normalizedUrl = url.trim(); + if (!normalizedUrl) { + return undefined; + } + + if (validYouTubeVideoId(normalizedUrl)) { + return normalizedUrl; + } + + try { + return extractVideoIdFromParsedUrl(new URL(normalizedYouTubeParseTarget(normalizedUrl))); + } catch { + return undefined; + } +} + +/** Builds a normal YouTube watch URL when the input contains a valid video ID. */ +export function buildYouTubeWatchUrl(url: string): string | undefined { + const videoId = extractYouTubeVideoId(url); + return videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined; +} + +/** Loads and memoizes the YouTube iframe API script. */ +export function loadYouTubeIframeApi(): Promise { + if (globalThis.YT?.Player) { + return Promise.resolve(globalThis.YT); + } + + if (youtubeIframeApiPromise) { + return youtubeIframeApiPromise; + } + + youtubeIframeApiPromise = new Promise((resolve) => { + const existingReadyHandler = globalThis.onYouTubeIframeAPIReady; + globalThis.onYouTubeIframeAPIReady = () => { + existingReadyHandler?.(); + if (globalThis.YT?.Player) { + resolve(globalThis.YT); + } + }; + + if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + const firstScript = document.getElementsByTagName('script')[0]; + firstScript.parentNode?.insertBefore(script, firstScript); + } + }); + + return youtubeIframeApiPromise; +} diff --git a/crates/client-web/src/main.ts b/crates/client-web/src/main.ts new file mode 100644 index 00000000..e924fd72 --- /dev/null +++ b/crates/client-web/src/main.ts @@ -0,0 +1,4 @@ +import './style.css'; +import { startApp } from './app'; + +startApp(); diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts new file mode 100644 index 00000000..306fff65 --- /dev/null +++ b/crates/client-web/src/mockApi.ts @@ -0,0 +1,1590 @@ +import type { + AppBootstrapResponse, + BootstrapUser, + CreateUserRequest, + ItemMetadataMatch, + ItemMetadataPerson, + ItemMetadataResponse, + LoginRequest, + LinkMetadataRequest, + MediaCollectionSummary, + MediaHome, + MediaItemDetail, + MediaItemSummary, + MediaLibrary, + MediaLibrarySettings, + MediaSearchResult, + MissingItemsCleanupResponse, + MetadataProviderStatus, + MetadataPersonItemCredit, + MetadataPersonResponse, + MetadataSearchResult, + LogEntriesResponse, + PlaybackDecision, + PlaybackProgressRequest, + ScheduledTaskId, + ScheduledTaskRunResponse, + ServerCapabilities, + SettingsResponse, + SettingsSnapshot, + SystemActivity, + SystemActivitiesResponse, + TokenResponse, + UpdateUserRequest, +} from './api'; + +let nextLibraryId = 4; +let nextUserId = 2; +const AUTH_TOKEN_STORAGE_KEY = 'koko-client-web-auth-token'; + +interface MockUserRecord extends BootstrapUser { + password: string; + pin?: string; +} + +const libraries: MediaLibrary[] = [ + { + id: 1, + name: 'Movies', + path: 'C:/Media/Movies', + paths: ['C:/Media/Movies', 'D:/Overflow/Movies'], + recursive: true, + kind: 'movies', + scanner: 'movies', + metadata_providers: ['tmdb'], + metadata_language_mode: 'auto', + metadata_languages: ['en-US'], + status: 'available', + scan_revision: 6, + last_scanned_at: 1760923200, + total_files: 2, + video_files: 2, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + missing_files: 0, + missing_items: 0, + }, + { + id: 2, + name: 'Shows', + path: 'C:/Media/Shows', + paths: ['C:/Media/Shows'], + recursive: true, + kind: 'shows', + scanner: 'shows', + metadata_providers: ['tmdb'], + metadata_language_mode: 'auto', + metadata_languages: ['en-US', 'ja-JP'], + status: 'available', + scan_revision: 5, + last_scanned_at: 1760923150, + total_files: 1, + video_files: 1, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + missing_files: 0, + missing_items: 0, + }, + { + id: 3, + name: 'Music', + path: 'C:/Media/Music', + paths: ['C:/Media/Music'], + recursive: true, + kind: 'music', + scanner: 'music', + metadata_providers: [], + metadata_language_mode: 'auto', + metadata_languages: ['en-US'], + status: 'available', + scan_revision: 4, + last_scanned_at: 1760923100, + total_files: 2, + video_files: 0, + audio_files: 2, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + missing_files: 0, + missing_items: 0, + }, +]; + +const items: MediaItemDetail[] = [ + { + id: 101, + library_id: 1, + item_type: 'movie', + display_title: 'Mock Movie', + relative_path: 'Action/mock-movie.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + duration_ms: 5_400_000, + width: 1920, + height: 1080, + modified_at: 1760923200, + file_size: 1_610_612_736, + container: 'mp4', + bit_rate: 2_400_000, + video_codec: 'h264', + audio_codec: 'aac', + metadata_json: JSON.stringify({ + format: { format_name: 'mp4', duration: '5400.0' }, + streams: [ + { codec_type: 'video', codec_name: 'h264' }, + { codec_type: 'audio', codec_name: 'aac', tags: { language: 'jpn', title: 'Japanese' }, disposition: { default: 1 } }, + { codec_type: 'audio', codec_name: 'aac', tags: { language: 'eng', title: 'English' }, disposition: { default: 0 } }, + ], + }, null, 2), + metadata_updated_at: 1760923200, + poster_url: '/api/v1/items/101/artwork?kind=poster', + backdrop_url: '/api/v1/items/101/artwork?kind=backdrop', + theme_song_url: '/api/v1/items/101/theme', + tagline: 'Welcome to the real world.', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + genres: ['Action', 'Science Fiction'], + release_year: 1999, + linked_media_type: 'movie', + has_metadata: true, + metadata_refresh_state: 'fresh', + artwork_updated_at: 1760923200, + trailer_title: 'Official Trailer', + trailer_url: 'https://www.youtube.com/watch?v=vKQi3bBA1y8', + extras: [ + { + extra_type: 'trailer', + title: 'Official Trailer', + url: 'https://www.youtube.com/watch?v=vKQi3bBA1y8', + duration_seconds: 136, + thumbnail_url: 'https://i.ytimg.com/vi/vKQi3bBA1y8/hqdefault.jpg', + }, + { + extra_type: 'theme_song', + title: 'Main Theme', + url: 'https://www.youtube.com/watch?v=SLBACEP6LsI', + duration_seconds: 74, + thumbnail_url: 'https://i.ytimg.com/vi/SLBACEP6LsI/hqdefault.jpg', + }, + ], + audio_tracks: [ + { index: 0, label: 'Japanese', codec: 'aac', language: 'jpn', default: true }, + { index: 1, label: 'English', codec: 'aac', language: 'eng', default: false }, + ], + subtitle_tracks: [ + { + index: 0, + label: 'EN', + format: 'SRT', + url: '/api/v1/items/101/subtitles/0', + }, + ], + hierarchy: [], + children: [], + }, + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + modified_at: 1760923150, + genres: ['Drama', 'Fantasy'], + linked_media_type: 'tv', + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + extras: [], + audio_tracks: [], + subtitle_tracks: [], + playback_target: { + item_id: 203, + start_ms: 74_000, + label: 'Resume S01E01', + display_title: 'Mock Episode', + season_number: 1, + episode_number: 1, + resume: true, + }, + restart_playback_target: { + item_id: 203, + start_ms: 0, + label: 'Start show', + display_title: 'Mock Episode', + season_number: 1, + episode_number: 1, + resume: false, + }, + hierarchy: [], + children: [ + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + }, + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + modified_at: 1760923150, + genres: ['Drama', 'Fantasy'], + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + extras: [], + audio_tracks: [], + subtitle_tracks: [], + playback_target: { + item_id: 203, + start_ms: 74_000, + label: 'Resume S01E01', + display_title: 'Mock Episode', + season_number: 1, + episode_number: 1, + resume: true, + }, + restart_playback_target: { + item_id: 203, + start_ms: 0, + label: 'Start season', + display_title: 'Mock Episode', + season_number: 1, + episode_number: 1, + resume: false, + }, + hierarchy: [ + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + children: [ + { + id: 203, + library_id: 2, + parent_id: 202, + item_type: 'episode', + display_title: 'Mock Episode', + relative_path: 'Mock Show/Season 1/episode-01.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + season_number: 1, + episode_number: 1, + duration_ms: 2_700_000, + width: 1280, + height: 720, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923100, + }, + ], + }, + { + id: 203, + library_id: 2, + parent_id: 202, + item_type: 'episode', + display_title: 'Mock Episode', + relative_path: 'Mock Show/Season 1/episode-01.mp4', + media_kind: 'video', + playable: true, + child_count: 0, + season_number: 1, + episode_number: 1, + duration_ms: 2_700_000, + width: 1280, + height: 720, + modified_at: 1760923100, + file_size: 810_612_736, + container: 'mp4', + bit_rate: 1_800_000, + video_codec: 'h264', + audio_codec: 'aac', + metadata_json: JSON.stringify({ format: { format_name: 'mp4', duration: '2700.0' } }, null, 2), + metadata_updated_at: 1760923100, + poster_url: '/api/v1/items/203/artwork?kind=poster', + tagline: 'Winter is coming.', + overview: 'A major fantasy series entry used as mock TV metadata for the browser client.', + genres: ['Drama', 'Fantasy'], + release_year: 2011, + linked_media_type: 'tv', + has_metadata: true, + metadata_refresh_state: 'fresh', + theme_song_url: 'https://www.youtube.com/watch?v=uXZd_W5B7N0', + extras: [], + audio_tracks: [], + subtitle_tracks: [], + hierarchy: [ + { + id: 201, + library_id: 2, + item_type: 'show', + display_title: 'Mock Show', + relative_path: 'Mock Show', + media_kind: 'video', + playable: false, + child_count: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + { + id: 202, + library_id: 2, + parent_id: 201, + item_type: 'season', + display_title: 'Season 1', + relative_path: 'Mock Show/Season 1', + media_kind: 'video', + playable: false, + child_count: 1, + season_number: 1, + duration_ms: 2_700_000, + genres: ['Drama', 'Fantasy'], + modified_at: 1760923150, + }, + ], + children: [], + }, + { + id: 103, + library_id: 3, + item_type: 'track', + display_title: 'Mock Song', + relative_path: 'mock-artist/mock-song.flac', + media_kind: 'audio', + playable: true, + child_count: 0, + duration_ms: 215_000, + modified_at: 1760923000, + file_size: 35_610_736, + container: 'flac', + bit_rate: 970_000, + audio_codec: 'flac', + metadata_json: JSON.stringify({ format: { format_name: 'flac', duration: '215.0' } }, null, 2), + metadata_updated_at: 1760923000, + genres: [], + extras: [], + audio_tracks: [], + subtitle_tracks: [], + hierarchy: [], + children: [], + }, + { + id: 104, + library_id: 3, + item_type: 'track', + display_title: 'Roadtrip Mix', + relative_path: 'mock-artist/roadtrip-mix.mp3', + media_kind: 'audio', + playable: true, + child_count: 0, + duration_ms: 198_000, + modified_at: 1760922900, + file_size: 8_610_736, + container: 'mp3', + bit_rate: 320_000, + audio_codec: 'mp3', + metadata_json: JSON.stringify({ format: { format_name: 'mp3', duration: '198.0' } }, null, 2), + metadata_updated_at: 1760922900, + genres: [], + extras: [], + audio_tracks: [], + subtitle_tracks: [], + hierarchy: [], + children: [], + }, +]; + +const metadataProviders: MetadataProviderStatus[] = [ + { + id: 'tmdb', + display_name: 'TheMovieDB', + description: 'Primary movie and television metadata provider for Koko.', + supported_kinds: ['movies', 'shows'], + requires_api_key: true, + implemented: true, + role: 'primary', + extends_provider_ids: [], + enabled: true, + configured: true, + language: 'en-US', + attribution_text: 'Metadata and artwork provided by The Movie Database (TMDB).', + attribution_url: 'https://www.themoviedb.org/', + logo_light_url: undefined, + logo_dark_url: undefined, + }, + { + id: 'tvdb', + display_name: 'TheTVDB', + description: 'Alternative movie and television metadata provider with strong series and episode coverage.', + supported_kinds: ['movies', 'shows'], + requires_api_key: true, + implemented: true, + role: 'primary', + extends_provider_ids: [], + enabled: false, + configured: false, + language: 'en-US', + attribution_text: 'Metadata and artwork provided by TheTVDB.', + attribution_url: 'https://thetvdb.com/', + logo_light_url: undefined, + logo_dark_url: undefined, + }, + { + id: 'musicbrainz', + display_name: 'MusicBrainz', + description: 'Planned music metadata provider for albums, artists, and tracks.', + supported_kinds: ['music'], + requires_api_key: false, + implemented: false, + role: 'primary', + extends_provider_ids: [], + enabled: false, + configured: true, + language: 'en-US', + attribution_text: 'MusicBrainz metadata is provided by MusicBrainz.', + attribution_url: 'https://musicbrainz.org/', + logo_light_url: undefined, + logo_dark_url: undefined, + }, + { + id: 'themerr', + display_name: 'ThemerrDB', + description: 'Secondary theme-song provider for linked movie and show metadata.', + supported_kinds: ['movies', 'shows'], + requires_api_key: false, + implemented: true, + role: 'secondary', + extends_provider_ids: ['tmdb', 'tvdb'], + enabled: true, + configured: true, + language: 'en-US', + attribution_text: 'Theme metadata provided by ThemerrDB.', + attribution_url: 'https://app.lizardbyte.dev/ThemerrDB', + logo_light_url: undefined, + logo_dark_url: undefined, + }, +]; + +const metadataSearchResults: Record = { + 101: [ + { + provider_id: 'tmdb', + external_id: '603', + media_type: 'movie', + title: 'The Matrix', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + release_year: 1999, + }, + ], + 203: [ + { + provider_id: 'tmdb', + external_id: '1399', + media_type: 'tv', + title: 'Game of Thrones', + overview: 'Nine noble families wage war against each other in order to gain control over the mythical land of Westeros.', + artwork_url: 'https://image.tmdb.org/t/p/w500/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/suopoADq0k8YZr4dQXcU6pToj6s.jpg', + release_year: 2011, + }, + ], +}; + +const users: MockUserRecord[] = [ + { + id: 1, + username: 'admin', + password: 'adminpass', + admin: true, + birthday: '1990-01-01', + profile_image_url: undefined, + preferred_metadata_languages: ['en-US'], + }, +]; + +function activeMockUserId(): number | undefined { + const token = globalThis.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)?.trim(); + if (!token?.startsWith('mock-token-')) { + return undefined; + } + + const parsed = Number(token.slice('mock-token-'.length)); + return Number.isFinite(parsed) ? parsed : undefined; +} + +const itemMetadata: Record = { + 101: { + item_id: 101, + providers: metadataProviders, + matches: [ + { + id: 1, + provider_id: 'tmdb', + external_id: '603', + title: 'The Matrix', + overview: 'A computer hacker learns the true nature of reality and his role in the war against its controllers.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + release_year: 1999, + media_type: 'movie', + relation_kind: 'primary', + match_state: 'linked', + trailer_title: 'Official Trailer', + trailer_url: 'https://www.youtube.com/watch?v=vKQi3bBA1y8', + genres: ['Action', 'Science Fiction'], + people: [ + { id: 1, person_id: 1, external_id: '6384', name: 'Keanu Reeves', role: 'Actor', department: 'Cast', character_name: 'Neo', image_url: 'https://image.tmdb.org/t/p/w185/4D0PpNI0kmP58hgrwGC3wCjxhnm.jpg', profile_url: 'https://www.themoviedb.org/person/6384', sort_order: 0 }, + { id: 2, person_id: 2, external_id: '2975', name: 'Laurence Fishburne', role: 'Actor', department: 'Cast', character_name: 'Morpheus', image_url: 'https://image.tmdb.org/t/p/w185/8suOhUmPbfKqDQ17jQ1Gy0mI3P4.jpg', profile_url: 'https://www.themoviedb.org/person/2975', sort_order: 1 }, + { id: 3, person_id: 3, external_id: '9340', name: 'Carrie-Anne Moss', role: 'Actor', department: 'Cast', character_name: 'Trinity', image_url: 'https://image.tmdb.org/t/p/w185/xD4jTA3KmVp5Rq3aHcymL9DUGjD.jpg', profile_url: 'https://www.themoviedb.org/person/9340', sort_order: 2 }, + { id: 4, person_id: 4, external_id: '9339', name: 'Lana Wachowski', role: 'Director', department: 'Directing', profile_url: 'https://www.themoviedb.org/person/9339', sort_order: 10000 }, + { id: 5, person_id: 5, external_id: '9341', name: 'Lilly Wachowski', role: 'Director', department: 'Directing', profile_url: 'https://www.themoviedb.org/person/9341', sort_order: 10001 }, + ], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: 1760923200, + updated_at: 1760923200, + }, + { + id: 2, + provider_id: 'themerr', + external_id: 'movie:tmdb:603', + media_type: 'movie', + relation_kind: 'secondary', + match_state: 'linked', + theme_song_url: 'https://www.youtube.com/watch?v=SLBACEP6LsI', + genres: [], + people: [], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: 1760923200, + updated_at: 1760923200, + }, + ], + }, + 201: { + item_id: 201, + providers: metadataProviders, + matches: metadataMatchesWithSecondaries( + items.find((item) => item.id === 201), + { + id: 3, + provider_id: 'tmdb', + external_id: '1399', + title: 'Game of Thrones', + overview: 'Nine noble families wage war against each other in order to gain control over the mythical land of Westeros.', + artwork_url: 'https://image.tmdb.org/t/p/w500/u3bZgnGQ9T01sWNhyveQz0wH0Hl.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/suopoADq0k8YZr4dQXcU6pToj6s.jpg', + release_year: 2011, + media_type: 'tv', + relation_kind: 'primary', + match_state: 'linked', + genres: ['Drama', 'Fantasy'], + people: [], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: 1760923200, + updated_at: 1760923200, + }, + ), + }, + 202: { + item_id: 202, + providers: metadataProviders, + matches: [], + }, + 203: { + item_id: 203, + providers: metadataProviders, + matches: [], + }, + 103: { + item_id: 103, + providers: metadataProviders, + matches: [], + }, + 104: { + item_id: 104, + providers: metadataProviders, + matches: [], + }, +}; + +function themerrSecondaryMatch( + item: MediaItemSummary, + primaryMatch: ItemMetadataMatch, + id: number, +): ItemMetadataMatch | undefined { + if (item.item_type !== 'movie' && item.item_type !== 'show') { + return undefined; + } + if (primaryMatch.provider_id !== 'tmdb') { + return undefined; + } + + return { + id, + provider_id: 'themerr', + external_id: `${item.item_type}:tmdb:${primaryMatch.external_id}`, + media_type: item.item_type, + relation_kind: 'secondary', + match_state: 'linked', + theme_song_url: item.item_type === 'show' + ? 'https://www.youtube.com/watch?v=uXZd_W5B7N0' + : 'https://www.youtube.com/watch?v=SLBACEP6LsI', + genres: [], + people: [], + locale_key: 'en-US', + refresh_state: 'fresh', + last_refreshed_at: primaryMatch.last_refreshed_at, + updated_at: primaryMatch.updated_at, + }; +} + +function metadataMatchesWithSecondaries( + item: MediaItemSummary | undefined, + primaryMatch: ItemMetadataMatch, +): ItemMetadataMatch[] { + const secondaryMatch = item + ? themerrSecondaryMatch(item, primaryMatch, primaryMatch.id + 1) + : undefined; + return secondaryMatch ? [primaryMatch, secondaryMatch] : [primaryMatch]; +} + +interface MockPlaybackProgress extends PlaybackProgressRequest { + watch_count: number; + last_watched_at?: number; +} + +const playbackProgress = new Map(); +playbackProgress.set('1:101', { position_ms: 1_260_000, duration_ms: 5_400_000, completed: false, watch_count: 0 }); +playbackProgress.set('1:103', { position_ms: 74_000, duration_ms: 215_000, completed: false, watch_count: 0 }); + +function applyMockPlaybackProgress(item: T): T { + const userId = activeMockUserId(); + const progress = userId === undefined ? undefined : playbackProgress.get(`${userId}:${item.id}`); + if (!progress) { + return item; + } + + return { + ...item, + playback_position_ms: progress.position_ms, + playback_duration_ms: progress.duration_ms, + playback_completed: progress.completed, + watch_count: progress.watch_count, + last_watched_at: progress.last_watched_at, + }; +} + +const collections: MediaCollectionSummary[] = [ + { + id: 'tmdb:2344', + provider_id: 'tmdb', + external_id: '2344', + name: 'The Matrix Collection', + overview: 'A cyberpunk science-fiction collection centered around Neo, Zion, and the war against the machines.', + artwork_url: 'https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', + backdrop_url: 'https://image.tmdb.org/t/p/w1280/icmmSD4vTTDKOq2vvdulafOGw93.jpg', + theme_song_url: 'https://www.youtube.com/watch?v=SLBACEP6LsI', + item_ids: [101], + item_count: 1, + }, +]; + +let settings: SettingsSnapshot = { + general: { + data_dir: 'C:/Users/Mock/AppData/Local/Koko/data', + }, + media: { + missing_item_auto_delete_days: null, + libraries: [ + { + name: 'Movies', + path: 'C:/Media/Movies', + paths: ['C:/Media/Movies', 'D:/Overflow/Movies'], + recursive: true, + kind: 'movies', + scanner: 'auto', + metadata_providers: ['tmdb'], + metadata_language_mode: 'auto', + metadata_languages: ['en-US'], + allowed_user_ids: [], + }, + { + name: 'Shows', + path: 'C:/Media/Shows', + paths: ['C:/Media/Shows'], + recursive: true, + kind: 'shows', + scanner: 'auto', + metadata_providers: ['tmdb'], + metadata_language_mode: 'auto', + metadata_languages: ['en-US', 'ja-JP'], + allowed_user_ids: [], + }, + { + name: 'Music', + path: 'C:/Media/Music', + paths: ['C:/Media/Music'], + recursive: true, + kind: 'music', + scanner: 'auto', + metadata_providers: [], + metadata_language_mode: 'auto', + metadata_languages: ['en-US'], + allowed_user_ids: [], + }, + ], + }, + metadata: { + providers: [ + { + id: 'tmdb', + enabled: true, + api_key: null, + api_key_configured: true, + language: 'en-US', + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1000, + }, + { + id: 'tvdb', + enabled: false, + api_key: null, + api_key_configured: false, + language: 'en-US', + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1000, + }, + { + id: 'themerr', + enabled: true, + api_key: null, + api_key_configured: false, + language: 'en-US', + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1000, + }, + ], + refresh_interval_days: 30, + }, + scheduled_tasks: { + enabled: true, + window: { + start_time: '02:00', + stop_time: '06:00', + weekdays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + }, + metadata_refresh: { + enabled: true, + }, + trash_cleanup: { + enabled: false, + missing_item_auto_delete_days: null, + interval_days: 1, + }, + database_maintenance: { + enabled: true, + interval_days: 7, + }, + }, + server: { + use_https: false, + address: '127.0.0.1', + port: 9191, + cert_path: 'cert.pem', + key_path: 'key.pem', + use_custom_certs: false, + }, + ffmpeg: { + ffmpeg_path: 'ffmpeg', + ffprobe_path: 'ffprobe', + }, +}; + +export function getMockCapabilities(): ServerCapabilities { + return { + app_name: 'Koko', + version: '0.0.0-dev', + server_url: 'http://127.0.0.1:9191', + https_enabled: false, + libraries_configured: libraries.length, + api_versions: ['v1'], + transcoding: { + ffmpeg: { + available: true, + version: 'ffmpeg mock build', + }, + ffprobe: { + available: true, + version: 'ffprobe mock build', + }, + }, + }; +} + +export function getMockBootstrap(): AppBootstrapResponse { + const currentUser = users.find((user) => user.id === activeMockUserId()); + return { + has_users: users.length > 0, + current_user: currentUser ? toUserSummary(currentUser) : undefined, + }; +} + +export function loginMockUser(request: LoginRequest): TokenResponse { + const user = users.find((candidate) => { + return candidate.username === request.username && candidate.password === request.password; + }); + if (!user) { + throw new Error('401 Unauthorized'); + } + return { token: `mock-token-${user.id}` }; +} + +export function createMockUser(request: CreateUserRequest): string { + const currentUser = users.find((user) => user.id === activeMockUserId()); + if (users.length > 0 && currentUser === undefined) { + throw new Error('401 Unauthorized'); + } + + if (users.length > 0 && !currentUser?.admin) { + throw new Error('403 Forbidden'); + } + + if (users.some((user) => user.username.toLowerCase() === request.username.trim().toLowerCase())) { + throw new Error('409 Conflict'); + } + + users.push({ + id: nextUserId, + username: request.username.trim(), + password: request.password, + pin: request.pin, + admin: users.length === 0 || request.admin, + birthday: request.birthday?.trim() || undefined, + profile_image_url: mockProfileImageUrl(request.profile_image_upload), + preferred_metadata_languages: request.preferred_metadata_languages?.length + ? request.preferred_metadata_languages + : ['en-US'], + }); + nextUserId += 1; + return 'User created'; +} + +export function getMockUsers(): BootstrapUser[] { + return users.map(toUserSummary); +} + +export function updateMockUser(userId: number, request: UpdateUserRequest): BootstrapUser { + const currentUser = users.find((user) => user.id === activeMockUserId()); + if (!currentUser) { + throw new Error('401 Unauthorized'); + } + if (!currentUser.admin) { + throw new Error('403 Forbidden'); + } + + const user = users.find((candidate) => candidate.id === userId); + if (!user) { + throw new Error('404 Not Found'); + } + + const username = request.username.trim(); + if (!username) { + throw new Error('400 Bad Request'); + } + if (users.some((candidate) => candidate.id !== userId && candidate.username.toLowerCase() === username.toLowerCase())) { + throw new Error('409 Conflict'); + } + if (user.admin && !request.admin && users.filter((candidate) => candidate.admin).length <= 1) { + throw new Error('400 Bad Request'); + } + + user.username = username; + user.admin = request.admin; + user.birthday = request.birthday?.trim() || undefined; + const nextProfileImageUrl = mockProfileImageUrl(request.profile_image_upload); + if (nextProfileImageUrl || request.remove_profile_image) { + user.profile_image_url = nextProfileImageUrl; + } + user.preferred_metadata_languages = request.preferred_metadata_languages?.length + ? request.preferred_metadata_languages + : ['en-US']; + return toUserSummary(user); +} + +function toUserSummary(user: MockUserRecord): BootstrapUser { + return { + id: user.id, + username: user.username, + admin: user.admin, + birthday: user.birthday, + profile_image_url: user.profile_image_url, + preferred_metadata_languages: user.preferred_metadata_languages ?? ['en-US'], + }; +} + +export function getMockLibraries(): MediaLibrary[] { + syncAllMockLibraryRefreshProgress(); + return [...libraries]; +} + +function syncMockLibraryRefreshProgress(libraryId: number): void { + const library = libraries.find((candidate) => candidate.id === libraryId); + if (!library) { + return; + } + + const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata); + library.metadata_refresh_total = refreshableItems.length; + library.metadata_refresh_pending = refreshableItems.filter((item) => item.metadata_refresh_state === 'pending').length; + library.metadata_refresh_failed = refreshableItems.filter((item) => item.metadata_refresh_state === 'error').length; + library.metadata_refresh_completed = Math.max(0, library.metadata_refresh_total - library.metadata_refresh_pending); +} + +function syncAllMockLibraryRefreshProgress(): void { + libraries.forEach((library) => { + syncMockLibraryRefreshProgress(library.id); + }); +} + +export function getMockMetadataProviders(): MetadataProviderStatus[] { + return metadataProviders.map((provider) => ({ ...provider })); +} + +export function getMockSystemActivities(): SystemActivitiesResponse { + const now = Math.floor(Date.now() / 1000); + const activities = libraries.reduce((entries, library) => { + const pendingItems = items.filter((item) => item.library_id === library.id && item.metadata_refresh_state === 'pending'); + if (!pendingItems.length) { + return entries; + } + + entries.push({ + id: `mock-activity-library-${library.id}`, + category: 'metadata_refresh', + scope: 'library', + source: 'mock_refresh', + state: 'running', + label: `Refresh metadata for ${library.name}`, + provider_id: 'tmdb', + library_id: library.id, + item_ids: pendingItems.map((item) => item.id), + total_items: pendingItems.length, + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: now, + updated_at: now, + }); + + return entries; + }, []); + + return { + generated_at: now, + activities, + }; +} + +export function getMockLogs( + level?: string, + moduleFilter?: string, + search?: string, + since?: string, + until?: string, + limit = 200, +): LogEntriesResponse { + const sinceTime = since ? new Date(since).getTime() : Number.NaN; + const untilTime = until ? new Date(until).getTime() : Number.NaN; + const entries = [ + { + timestamp: '2026-04-22T09:12:35.853-04:00', + level: 'INFO', + module: 'koko::web::routes::media', + source_file_path: 'src/web/routes/media.rs', + line_number: 540, + message: 'Completed TMDB metadata refresh for media item 201 "Mock Show" (show) in library 2 [Mock Show]', + }, + { + timestamp: '2026-04-22T09:12:00.810-04:00', + level: 'WARN', + module: 'koko::web::routes::media', + source_file_path: 'src/web/routes/media.rs', + line_number: 589, + message: 'Failed to fetch refreshed TMDB metadata snapshot for media item 417 "Season 1" (season) in library 2 [The Simpsons/Season 1] using target tv:456:season:1 (tv_season): TMDB season lookup failed with status 404 Not Found', + }, + { + timestamp: '2026-04-22T09:10:49.079-04:00', + level: 'DEBUG', + module: 'reqwest::connect', + source_file_path: 'src/connect.rs', + line_number: 118, + message: 'starting new connection: https://api.themoviedb.org/', + }, + ].filter((entry) => { + const levelMatches = level ? entry.level.toLowerCase() === level.toLowerCase() : true; + const moduleMatches = moduleFilter ? entry.module.toLowerCase().includes(moduleFilter.toLowerCase()) : true; + const searchMatches = search + ? `${entry.message} ${entry.module} ${entry.source_file_path}`.toLowerCase().includes(search.toLowerCase()) + : true; + const timestamp = new Date(entry.timestamp).getTime(); + const sinceMatches = Number.isNaN(sinceTime) || timestamp >= sinceTime; + const untilMatches = Number.isNaN(untilTime) || timestamp <= untilTime; + return levelMatches && moduleMatches && searchMatches && sinceMatches && untilMatches; + }); + + return { + log_path: 'C:/Users/Mock/AppData/Local/Koko/data/koko.log', + entries: entries.slice(0, Math.max(1, limit)), + }; +} + +export function getMockItem(itemId: number): MediaItemDetail | undefined { + const item = items.find((item) => item.id === itemId); + return item ? applyMockPlaybackProgress(item) : undefined; +} + +export function getMockItemMetadata(itemId: number): ItemMetadataResponse | undefined { + return itemMetadata[itemId]; +} + +export function searchMockItemMetadata(itemId: number, query?: string): MetadataSearchResult[] { + const results = metadataSearchResults[itemId] ?? []; + const normalized = query?.trim().toLowerCase(); + if (!normalized) { + return [...results]; + } + + return results.filter((result) => { + return result.title.toLowerCase().includes(normalized) + || result.overview?.toLowerCase().includes(normalized); + }); +} + +export function getMockPlayback(itemId: number): PlaybackDecision { + const item = getMockItem(itemId); + if (!item) { + throw new Error('404 Not Found'); + } + + if (!item.playable) { + return { + item_id: itemId, + can_direct_play: false, + transcode_required: false, + video_transcode_required: false, + audio_transcode_required: false, + reason: 'This item is a container and cannot be played directly.', + stream_url: undefined, + mime_type: undefined, + }; + } + + const canDirectPlay = item.container === 'mp4' || item.container === 'mp3' || item.container === 'flac'; + return { + item_id: itemId, + can_direct_play: canDirectPlay, + transcode_required: !canDirectPlay, + video_transcode_required: !canDirectPlay && item.media_kind === 'video', + audio_transcode_required: !canDirectPlay, + reason: canDirectPlay + ? 'Browser direct play is supported for this item.' + : 'A future FFmpeg-backed transcode path will be required for browser playback.', + stream_url: canDirectPlay ? `/api/v1/items/${itemId}/stream` : undefined, + mime_type: item.media_kind === 'video' ? 'video/mp4' : 'audio/mpeg', + }; +} + +export function getMockHome(libraryId?: number): MediaHome { + const filteredItems = getMockItems(libraryId); + const continueWatching = filteredItems.filter((item) => { + const userId = activeMockUserId(); + const progress = userId === undefined ? undefined : playbackProgress.get(`${userId}:${item.id}`); + return Boolean(progress && !progress.completed && progress.position_ms > 0); + }); + const recentlyAdded = [...filteredItems].sort((left, right) => (right.modified_at ?? 0) - (left.modified_at ?? 0)); + const recommended = filteredItems.filter((item) => !continueWatching.some((candidate) => candidate.id === item.id)); + + return { + library_id: libraryId, + shelves: [ + { id: 'continue_watching', title: 'Continue watching', items: continueWatching }, + { id: 'recently_added', title: 'Recently added', items: recentlyAdded }, + { id: 'recommended', title: 'Recommended', items: recommended }, + ], + collections: collections.filter((collection) => collection.item_ids.some((itemId) => filteredItems.some((item) => item.id === itemId))), + }; +} + +export function getMockItems(libraryId?: number): MediaItemSummary[] { + return items + .filter((item) => (typeof libraryId === 'number' ? item.library_id === libraryId : true)) + .map(({ file_size: _fileSize, container: _container, bit_rate: _bitRate, video_codec: _videoCodec, audio_codec: _audioCodec, metadata_json: _metadataJson, metadata_updated_at: _metadataUpdatedAt, ...summary }) => applyMockPlaybackProgress(summary)); +} + +export function searchMockItems(query: string): MediaSearchResult[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return []; + } + + const results: MediaSearchResult[] = getMockItems().filter((item) => { + return item.display_title.toLowerCase().includes(normalizedQuery) + || item.relative_path.toLowerCase().includes(normalizedQuery) + || item.media_kind.toLowerCase().includes(normalizedQuery); + }).map((item) => ({ result_type: 'item', item })); + + results.push(...collections + .filter((collection) => { + return collection.name.toLowerCase().includes(normalizedQuery); + }) + .map((collection) => ({ result_type: 'collection' as const, collection }))); + + const people = new Map(); + for (const response of Object.values(itemMetadata)) { + for (const match of response.matches) { + for (const person of match.people) { + if ( + person.name.toLowerCase().includes(normalizedQuery) + || person.character_name?.toLowerCase().includes(normalizedQuery) + ) { + people.set(person.person_id, { + result_type: 'person', + person: { + id: person.person_id, + provider_id: match.provider_id, + external_id: person.external_id, + locale_key: person.locale_key ?? 'eng', + name: person.name, + known_for: [], + profile_url: person.profile_url, + image_url: person.image_url, + cached_image_path: person.cached_image_path, + }, + }); + } + } + } + } + + results.push(...people.values()); + return results; +} + +export function getMockSettings(): SettingsResponse { + return { + settings: structuredClone(settings), + settings_path: 'C:/Users/Mock/AppData/Local/Koko/settings.yml', + }; +} + +export function updateMockSettings(nextSettings: SettingsSnapshot): SettingsResponse { + settings = structuredClone(nextSettings); + return getMockSettings(); +} + +function mockProfileImageUrl(upload?: { mime_type: string; data_base64: string }): string | undefined { + if (!upload?.data_base64) { + return undefined; + } + return `data:${upload.mime_type};base64,${upload.data_base64}`; +} + +export function clearMockMetadataCache(): { removed_files: number } { + return { removed_files: 0 }; +} + +export function runMockScheduledTask(taskId: ScheduledTaskId): ScheduledTaskRunResponse { + if (!['metadata_refresh', 'trash_cleanup', 'database_maintenance'].includes(taskId)) { + throw new Error('404 Not Found'); + } + + return { + task_id: taskId, + started: true, + message: `${taskId.replace(/_/g, ' ')} started`, + }; +} + +export function addMockLibrary(request: { library: MediaLibrarySettings }): SettingsResponse { + const normalizedLibrary = structuredClone(request.library); + normalizedLibrary.paths = normalizedLibrary.paths.length ? normalizedLibrary.paths : [normalizedLibrary.path].filter(Boolean); + normalizedLibrary.path = normalizedLibrary.paths[0] ?? normalizedLibrary.path; + normalizedLibrary.metadata_languages = normalizedLibrary.metadata_languages?.length ? normalizedLibrary.metadata_languages : ['en-US']; + normalizedLibrary.metadata_language_mode = normalizedLibrary.metadata_language_mode ?? 'auto'; + normalizedLibrary.scanner = normalizedLibrary.scanner ?? 'auto'; + normalizedLibrary.allowed_user_ids = normalizedLibrary.allowed_user_ids ?? []; + settings.media.libraries.push(normalizedLibrary); + libraries.push({ + id: nextLibraryId, + name: normalizedLibrary.name, + path: normalizedLibrary.path, + paths: [...normalizedLibrary.paths], + recursive: normalizedLibrary.recursive, + kind: normalizedLibrary.kind, + scanner: normalizedLibrary.scanner, + metadata_providers: [...normalizedLibrary.metadata_providers], + metadata_language_mode: normalizedLibrary.metadata_language_mode, + metadata_languages: [...normalizedLibrary.metadata_languages], + status: 'available', + scan_revision: 1, + last_scanned_at: Math.floor(Date.now() / 1000), + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + missing_files: 0, + missing_items: 0, + }); + nextLibraryId += 1; + return getMockSettings(); +} + +export function removeMockLibrary(libraryIndex: number): SettingsResponse { + if (libraryIndex < 0 || libraryIndex >= settings.media.libraries.length) { + throw new Error('404 Not Found'); + } + + const [removedLibrary] = settings.media.libraries.splice(libraryIndex, 1); + const libraryMatchIndex = libraries.findIndex((library) => { + return library.name === removedLibrary.name && library.path === removedLibrary.path; + }); + if (libraryMatchIndex >= 0) { + libraries.splice(libraryMatchIndex, 1); + } + + return getMockSettings(); +} + +export function deleteMockMissingItems(libraryId: number): MissingItemsCleanupResponse { + const library = libraries.find((candidate) => candidate.id === libraryId); + if (!library) { + throw new Error('404 Not Found'); + } + + const deletedItems = items.filter((item) => item.library_id === libraryId && item.missing_since).length; + for (let index = items.length - 1; index >= 0; index -= 1) { + if (items[index].library_id === libraryId && items[index].missing_since) { + items.splice(index, 1); + } + } + + collections.forEach((collection) => { + collection.item_ids = collection.item_ids.filter((itemId) => items.some((item) => item.id === itemId)); + collection.item_count = collection.item_ids.length; + }); + library.missing_files = 0; + library.missing_items = 0; + + return { + library_id: libraryId, + deleted_files: deletedItems, + deleted_items: deletedItems, + removed_collection_items: 0, + library: { ...library }, + }; +} + +export function updateMockPlaybackProgress(itemId: number, payload: PlaybackProgressRequest): void { + const userId = activeMockUserId(); + if (userId !== undefined) { + const key = `${userId}:${itemId}`; + const existing = playbackProgress.get(key); + const completedTransition = payload.completed && !existing?.completed; + playbackProgress.set(key, { + ...payload, + watch_count: (existing?.watch_count ?? 0) + (completedTransition ? 1 : 0), + last_watched_at: completedTransition + ? Math.floor(Date.now() / 1000) + : existing?.last_watched_at, + }); + } +} + +export function linkMockItemMetadata(itemId: number, request: LinkMetadataRequest): ItemMetadataMatch { + const candidate = (metadataSearchResults[itemId] ?? []).find((result) => { + return result.provider_id === request.provider_id + && result.external_id === request.external_id + && result.media_type === request.media_type; + }); + if (!candidate) { + throw new Error('404 Not Found'); + } + + const linkedMatch: ItemMetadataMatch = { + id: Date.now(), + provider_id: candidate.provider_id, + external_id: candidate.external_id, + title: candidate.title, + overview: candidate.overview, + artwork_url: candidate.artwork_url, + backdrop_url: candidate.backdrop_url, + release_year: candidate.release_year, + media_type: candidate.media_type, + relation_kind: 'primary', + match_state: 'linked', + genres: [], + people: [], + locale_key: 'en-US', + updated_at: Math.floor(Date.now() / 1000), + }; + + const item = items.find((candidate) => candidate.id === itemId); + + itemMetadata[itemId] = { + item_id: itemId, + providers: metadataProviders, + matches: metadataMatchesWithSecondaries(item, linkedMatch), + }; + if (item) { + item.display_title = candidate.title; + } + + return linkedMatch; +} + +function getMockPersonCreditsForMatch( + itemId: number, + item: MediaItemSummary, + match: ItemMetadataMatch, + personId: number, +): MetadataPersonItemCredit[] { + return match.people + .filter((person) => person.person_id === personId) + .map((person) => mockPersonCredit(itemId, item, match, person)); +} + +function mockPersonCredit( + itemId: number, + item: MediaItemSummary, + match: ItemMetadataMatch, + person: ItemMetadataPerson, +): MetadataPersonItemCredit { + return { + credit: { + id: person.id, + metadata_link_id: match.id, + media_item_id: itemId, + role: person.role, + department: person.department, + character_name: person.character_name, + sort_order: person.sort_order, + }, + item, + hierarchy: item.hierarchy ?? [], + }; +} + +function getMockPersonCreditsForResponse(response: ItemMetadataResponse, personId: number): MetadataPersonItemCredit[] { + const item = items.find((candidate) => candidate.id === response.item_id); + if (!item) { + return []; + } + + return response.matches.flatMap((match) => getMockPersonCreditsForMatch(response.item_id, item, match, personId)); +} + +export function getMockPerson(personId: number): MetadataPersonResponse { + const credits = Object.values(itemMetadata) + .flatMap((response) => getMockPersonCreditsForResponse(response, personId)); + const firstCredit = credits[0]; + const personCredit = Object.values(itemMetadata) + .flatMap((response) => response.matches) + .flatMap((match) => match.people) + .find((person) => person.person_id === personId); + if (!firstCredit || !personCredit) { + throw new Error('404 Not Found'); + } + + return { + person: { + id: personCredit.person_id, + provider_id: 'tmdb', + external_id: personCredit.external_id, + locale_key: 'en-US', + name: personCredit.name, + known_for: ['The Matrix'], + biography: personCredit.name === 'Keanu Reeves' + ? 'Canadian actor known for action films, science fiction, and understated dramatic work.' + : undefined, + gender: personCredit.name === 'Carrie-Anne Moss' ? 'Female' : 'Male', + birthday: personCredit.name === 'Keanu Reeves' ? '1964-09-02' : undefined, + birth_place: personCredit.name === 'Keanu Reeves' ? 'Beirut, Lebanon' : undefined, + profile_url: personCredit.profile_url, + image_url: personCredit.image_url, + }, + credits, + }; +} + +export function refreshMockItemMetadata(itemId: number): ItemMetadataMatch { + const response = itemMetadata[itemId]; + const existingMatch = response?.matches.find((match) => match.relation_kind === 'primary') + ?? response?.matches[0]; + if (!existingMatch) { + throw new Error('404 Not Found'); + } + + const pendingMatch: ItemMetadataMatch = { + ...existingMatch, + refresh_state: 'pending', + updated_at: Math.floor(Date.now() / 1000), + }; + + itemMetadata[itemId] = { + ...response, + matches: response.matches.map((match) => match.id === existingMatch.id ? pendingMatch : match), + }; + + const item = items.find((candidate) => candidate.id === itemId); + if (item) { + item.metadata_refresh_state = 'pending'; + syncMockLibraryRefreshProgress(item.library_id); + + globalThis.setTimeout(() => { + const source = (metadataSearchResults[itemId] ?? []).find((candidate) => { + return candidate.provider_id === existingMatch.provider_id + && candidate.external_id === existingMatch.external_id + && candidate.media_type === existingMatch.media_type; + }); + const refreshedAt = Math.floor(Date.now() / 1000); + const refreshedMatch: ItemMetadataMatch = { + ...pendingMatch, + title: source?.title ?? existingMatch.title, + overview: source?.overview ?? existingMatch.overview, + artwork_url: source?.artwork_url ?? existingMatch.artwork_url, + backdrop_url: source?.backdrop_url ?? existingMatch.backdrop_url, + release_year: source?.release_year ?? existingMatch.release_year, + refresh_state: 'fresh', + updated_at: refreshedAt, + }; + + itemMetadata[itemId] = { + ...response, + matches: response.matches.map((match) => match.id === existingMatch.id ? refreshedMatch : match), + }; + item.display_title = refreshedMatch.title ?? item.display_title; + item.overview = refreshedMatch.overview ?? item.overview; + item.release_year = refreshedMatch.release_year ?? item.release_year; + item.linked_media_type = refreshedMatch.media_type ?? item.linked_media_type; + item.metadata_refresh_state = 'fresh'; + item.artwork_updated_at = refreshedAt; + syncMockLibraryRefreshProgress(item.library_id); + }, 900); + } + + return pendingMatch; +} + +export function refreshMockLibraryMetadata(libraryId: number): MediaLibrary { + const library = libraries.find((candidate) => candidate.id === libraryId); + if (!library) { + throw new Error('404 Not Found'); + } + + const refreshableItems = items.filter((item) => item.library_id === libraryId && item.has_metadata); + refreshableItems.forEach((item) => { + item.metadata_refresh_state = 'pending'; + const response = itemMetadata[item.id]; + if (response) { + itemMetadata[item.id] = { + ...response, + matches: response.matches.map((match) => match.relation_kind === 'primary' + ? { + ...match, + refresh_state: 'pending', + updated_at: Math.floor(Date.now() / 1000), + } + : match), + }; + } + }); + syncMockLibraryRefreshProgress(libraryId); + + globalThis.setTimeout(() => { + const refreshedAt = Math.floor(Date.now() / 1000); + refreshableItems.forEach((item) => { + item.metadata_refresh_state = 'fresh'; + item.artwork_updated_at = refreshedAt; + const response = itemMetadata[item.id]; + if (response) { + itemMetadata[item.id] = { + ...response, + matches: response.matches.map((match) => match.relation_kind === 'primary' + ? { + ...match, + refresh_state: 'fresh', + updated_at: refreshedAt, + } + : match), + }; + } + }); + syncMockLibraryRefreshProgress(libraryId); + }, 1200); + + return { ...library }; +} diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css new file mode 100644 index 00000000..5104f099 --- /dev/null +++ b/crates/client-web/src/style.css @@ -0,0 +1,3038 @@ +:root { + --page-backdrop-image: none; + color-scheme: dark; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; + font-weight: 400; + background: #0c111d; + color: #f4f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(89, 124, 255, 0.18), transparent 24%), + radial-gradient(circle at bottom right, rgba(67, 214, 158, 0.16), transparent 22%), + #0c111d; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, #5d7bff, #7c5cff); + color: #fff; + padding: 0.8rem 1rem; + cursor: pointer; + transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease, background 160ms ease, outline-color 160ms ease; + box-shadow: 0 12px 30px rgba(93, 123, 255, 0.24); +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 14px 34px rgba(93, 123, 255, 0.38); + filter: brightness(1.1); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 3px solid #8bf3ca; + outline-offset: 3px; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.secondary-button { + background: rgba(255, 255, 255, 0.08); + box-shadow: none; + color: #dbe7ff; +} + +.secondary-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); +} + +.icon-only { + width: 2.35rem; + min-width: 2.35rem; + height: 2.35rem; + padding: 0; + justify-content: center; +} + +button.is-busy { + position: relative; + color: transparent; + pointer-events: none; +} + +button.is-busy::after { + content: ''; + position: absolute; + width: 1rem; + height: 1rem; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + animation: spin 0.85s linear infinite; +} + +.button-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.55rem; +} + +.button-content.icon-end { + flex-direction: row-reverse; +} + +.button-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.button-icon svg { + width: 1rem; + height: 1rem; + stroke-width: 2.1; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + padding: 0.8rem 0.9rem; +} + +textarea { + resize: vertical; + min-height: 6rem; +} + +fieldset { + margin: 0; + padding: 0; + border: 0; +} + +legend { + font-weight: 600; + margin-bottom: 0.6rem; +} + +#app { + min-height: 100vh; +} + +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.auth-panel { + width: min(480px, 100%); + padding: 1.4rem; +} + +.auth-header { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.auth-header h1, +.auth-copy h2 { + margin: 0; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.auth-form label { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.auth-error-panel { + margin-bottom: 1rem; +} + +.app-shell { + position: relative; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + min-height: 100vh; + height: 100vh; + align-items: stretch; + overflow: hidden; +} + +.app-shell.rail-collapsed { + grid-template-columns: 88px minmax(0, 1fr); +} + +.page-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.page-backdrop::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: min(78vw, 1280px); + height: min(78vh, 900px); + background-image: var(--page-backdrop-image, none); + background-position: center 18%; + background-repeat: no-repeat; + background-size: cover; + transform: scale(1.04); + transform-origin: top right; + mask-image: radial-gradient(ellipse at 70% 26%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.9) 28%, rgba(0, 0, 0, 0.58) 55%, rgba(0, 0, 0, 0.18) 76%, transparent 100%); + -webkit-mask-image: radial-gradient(ellipse at 70% 26%, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.9) 28%, rgba(0, 0, 0, 0.58) 55%, rgba(0, 0, 0, 0.18) 76%, transparent 100%); +} + +.page-backdrop::after { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 66% 22%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.16) 34%, rgba(12, 17, 29, 0.62) 72%, #0c111d 100%), + linear-gradient(180deg, rgba(12, 17, 29, 0.06) 0%, rgba(12, 17, 29, 0.18) 26%, rgba(12, 17, 29, 0.54) 54%, rgba(12, 17, 29, 0.84) 76%, #0c111d 100%), + linear-gradient(270deg, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.18) 18%, rgba(12, 17, 29, 0.78) 54%, #0c111d 100%); +} + +.app-shell > :not(.page-backdrop) { + position: relative; + z-index: 1; +} + +.library-rail { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 1.2rem; + width: 100%; + min-width: 0; + height: 100vh; + padding: 1.2rem 0.9rem; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(7, 11, 21, 0.92); + backdrop-filter: blur(18px); + overflow: hidden; +} + +.app-shell.rail-collapsed .library-rail { + max-width: 88px; +} + +.library-rail-top, +.library-rail-bottom { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.library-rail-top { + min-height: 0; + flex: 1; +} + +.library-rail-bottom { + margin-top: auto; + padding-top: 0.2rem; +} + +.rail-user-card { + display: flex; + gap: 0.7rem; + align-items: center; + padding: 0.85rem 0.9rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); + color: #dbe7ff; +} + +.rail-user-copy { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.rail-user-card span { + color: #9ab1d1; + font-size: 0.82rem; +} + +.user-avatar { + display: inline-grid; + place-items: center; + width: 2.3rem; + height: 2.3rem; + flex: 0 0 auto; + overflow: hidden; + border-radius: 999px; + background: #263f5f; + color: #dbe7ff; + font-weight: 700; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.edit-avatar { + width: 3rem; + height: 3rem; +} + +.brand-block { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.25rem 0.4rem; +} + +.brand-block h1 { + margin: 0; + font-size: 1rem; +} + +.brand-block p { + margin: 0.1rem 0 0; + font-size: 0.78rem; + color: #9ab1d1; +} + +.brand-mark { + width: 38px; + min-width: 38px; + flex: 0 0 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 14px; +} + +.logo-brand-mark { + overflow: hidden; +} + +.brand-logo { + width: 30px; + height: auto; + aspect-ratio: 1; + object-fit: contain; +} + +.is-hidden { + display: none !important; +} + +.brand-icon, +.rail-icon, +.card-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brand-icon svg, +.rail-icon svg, +.card-icon svg { + width: 1.1rem; + height: 1.1rem; + stroke-width: 2; +} + +.rail-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 0.2rem; +} + +.rail-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.7rem; + width: 100%; + padding: 0.85rem 0.9rem; + border-radius: 16px; + background: transparent; + box-shadow: none; + color: #b6c4d8; +} + +.rail-button.active, +.rail-button:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.rail-icon { + width: 1.5rem; + min-width: 1.5rem; + text-align: center; +} + +.library-rail.collapsed { + padding-inline: 0.7rem; +} + +.library-rail.collapsed .brand-block { + justify-content: center; +} + +.library-rail.collapsed .brand-block>div:last-child, +.library-rail.collapsed .rail-label, +.library-rail.collapsed .rail-user-card { + display: none; +} + +.library-rail.collapsed .rail-library-copy { + display: none; +} + +.library-rail.collapsed .rail-button { + justify-content: center; + padding-inline: 0.75rem; +} + +.rail-label { + max-width: 100%; + font-size: 0.92rem; + line-height: 1.2; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; +} + +.rail-library-copy { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-width: 0; +} + +.library-refresh-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.12rem; + height: 1.12rem; + flex: 0 0 auto; +} + +.library-refresh-ring { + position: relative; + width: 100%; + height: 100%; + border-radius: 999px; + background: conic-gradient(#5d7bff var(--library-refresh-progress, 0%), rgba(255, 255, 255, 0.14) 0); +} + +.library-refresh-ring::after { + content: ''; + position: absolute; + inset: 2px; + border-radius: inherit; + background: rgba(14, 20, 35, 0.96); +} + +.rail-settings { + width: 100%; +} + +.main-shell { + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + width: 100%; + padding: 1.2rem 1.4rem; + height: 100vh; + min-width: 0; + overflow-y: auto; +} + +.main-shell:has(.home-navbar) { + padding: 0; +} + +.main-shell:has(.home-navbar) .main-shell-inner { + gap: 0; +} + +.panel { + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(14, 20, 35, 0.82); + backdrop-filter: blur(18px); + border-radius: 24px; +} + +.main-shell-inner { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.content-navbar { + position: sticky; + top: 0; + z-index: 8; + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: end; + padding: 1rem 1.2rem; +} + +.content-navbar h2, +.player-header h2 { + margin: 0.1rem 0; +} + +.content-navbar-copy { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.content-navbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; + min-width: 0; +} + +.content-navbar-actions-stack { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + width: 100%; +} + +.content-navbar-actions-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + flex-wrap: wrap; +} + +.content-navbar-actions .search-form { + justify-content: flex-end; +} + +.home-navbar { + position: sticky; + top: 0; + z-index: 12; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-height: 3.75rem; + padding: 0.45rem 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(7, 11, 20, 0.96); + backdrop-filter: blur(18px); +} + +.home-navbar-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + min-width: 0; +} + +.icon-button { + width: 2.35rem; + height: 2.35rem; + padding: 0; + display: inline-grid; + place-items: center; + flex: 0 0 auto; +} + +.browse-tabs { + display: flex; + gap: 0.25rem; + padding: 0; + overflow-x: auto; +} + +.home-sticky-tabs { + position: sticky; + top: 5.3rem; + z-index: 7; +} + +.browse-tab-button { + flex: 0 0 auto; + background: transparent; + box-shadow: none; + color: #9ab1d1; + min-height: 2.35rem; + padding: 0 0.7rem; +} + +.browse-tab-button.active, +.browse-tab-button:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.settings-section-nav { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.settings-section-nav .secondary-button.active { + color: #061018; + background: #d8ffe9; +} + +.page-panel { + width: 100%; +} + +.eyebrow, +.label { + display: block; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #86a0c7; +} + +.error-panel { + padding: 1rem 1.2rem; + border-color: rgba(255, 132, 132, 0.35); + background: rgba(86, 24, 24, 0.38); + color: #ffd7d7; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 1rem; + min-height: 0; +} + +.content-column { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.shelf-stack, +.detail-panel { + padding: 1.2rem; +} + +.search-form { + display: flex; + width: min(100%, 360px); + align-items: stretch; +} + +.search-form input[type='search'] { + height: 2.75rem; + min-height: 2.75rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; +} + +.search-form input[type='search']::-webkit-search-cancel-button { + appearance: none; +} + +.search-toggle-button { + width: 2.75rem; + height: 2.75rem; + min-height: 2.75rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + box-shadow: none; +} + +.library-overview-panel { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 1.2rem; +} + +.library-overview-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.library-overview-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.65rem; + align-items: center; +} + +.library-overview-header h3, +.category-card-header strong { + margin: 0; +} + +.library-status-tags { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.library-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.8rem; +} + +.library-stat-card, +.category-card { + padding: 0.95rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.library-stat-card { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.library-stat-card strong { + font-size: 1.15rem; +} + +.library-overview-note { + margin: 0; +} + +.browse-section, +.placeholder-stack, +.browse-filter-detail { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.active-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + align-items: center; + padding: 0.85rem 0.95rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.browse-section-header { + margin-bottom: 0.1rem; +} + +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.9rem; +} + +.category-card-header { + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: center; +} + +.filter-card-button { + text-align: left; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: none; + color: inherit; +} + +.filter-card-button[style*='--collection-card-image'] { + background-image: var(--collection-card-image); + background-size: cover; + background-position: center; + border-color: transparent; +} + +.filter-card-button:hover { + border-color: rgba(93, 123, 255, 0.35); + background: rgba(255, 255, 255, 0.06); +} + +.shelf-stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.shelf-stack.panel { + border-radius: 0; +} + +.shelf { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.shelf-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.shelf-header h3, +.section-heading h3 { + margin: 0; +} + +.shelf-header span, +.metadata-match-meta, +.media-card-meta, +.detail-subtitle, +.muted, +.metadata-search-card p { + color: #9ab1d1; +} + +.shelf-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 184px; + gap: 0.9rem; + overflow-x: auto; + padding-bottom: 0.2rem; + align-items: start; +} + +.shelf-row-shell { + display: grid; + grid-template-columns: 2.3rem minmax(0, 1fr) 2.3rem; + gap: 0.55rem; + align-items: center; +} + +.shelf-row-shell.no-scroll { + grid-template-columns: minmax(0, 1fr); +} + +.shelf-scroll-button { + width: 2.3rem; + height: 2.3rem; + padding: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #dfe9ff; + box-shadow: none; +} + +.shelf-scroll-button.is-scroll-hidden { + visibility: hidden; + pointer-events: none; +} + +.shelf-row-shell.no-scroll .shelf-scroll-button { + display: none; +} + +.shelf-scroll-button:hover, +.shelf-scroll-button:focus-visible { + background: #d8ffe9; + color: #061018; +} + +.shelf-row .media-card { + width: 184px; +} + +.item-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.media-card { + display: flex; + flex-direction: column; + gap: 0.6rem; + align-items: stretch; + text-align: left; + padding: 0; + border-radius: 18px; + background: transparent; + box-shadow: none; +} + +.episode-card { + gap: 0.45rem; +} + +.media-card-art { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 18px; + padding: 0.9rem; + display: block; + background: linear-gradient(180deg, rgba(93, 123, 255, 0.8), rgba(22, 31, 54, 0.92)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.media-card-art.episode { + aspect-ratio: 16 / 9; +} + +.media-card-art.audio { + background: linear-gradient(180deg, rgba(66, 214, 158, 0.78), rgba(17, 44, 40, 0.94)); +} + +.media-card.is-missing .media-card-art { + border-color: rgba(255, 191, 84, 0.32); + box-shadow: inset 0 0 0 1px rgba(255, 191, 84, 0.14); +} + +.media-card-kind-row { + position: absolute; + top: 0.85rem; + right: 0.85rem; + left: 0.85rem; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: start; + pointer-events: none; +} + +.media-card-dynamic-badges { + position: absolute; + right: 0.85rem; + bottom: 0.85rem; + left: 0.85rem; + z-index: 2; + display: flex; + gap: 0.35rem; + align-items: end; + justify-content: space-between; + pointer-events: none; +} + +.media-card-state-badges, +.media-card-playback-badges { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.media-card-playback-badges { + margin-left: auto; + justify-content: flex-end; +} + +.media-card-kind, +.media-card-duration { + padding: 0.35rem 0.55rem; + border-radius: 999px; + background: rgba(10, 14, 24, 0.36); + font-size: 0.76rem; + white-space: nowrap; +} + +.media-card-status { + display: inline-flex; + align-items: center; + gap: 0.32rem; + padding: 0; + border-radius: 999px; + background: transparent; + font-size: 0.72rem; +} + +.media-card-status.icon-only { + width: 1.96rem; + min-width: 1.96rem; + height: 1.96rem; + min-height: 1.96rem; + justify-content: center; + padding: 0; +} + +.media-card-status.has-multiple { + min-height: 1.45rem; + padding-inline: 0.38rem; +} + +.media-card-status.is-unmatched { + padding: 0.24rem 0.34rem; + background: rgba(10, 14, 24, 0.52); + color: #ffe5b5; +} + +.media-card-status.is-unmatched.icon-only { + width: 1.45rem; + min-width: 1.45rem; + height: 1.45rem; + min-height: 1.45rem; +} + +.media-card-status.is-loading { + color: #dce6ff; +} + +.media-card-dynamic-badges .media-card-status.is-loading { + justify-content: center; + background: rgba(10, 14, 24, 0.52); +} + +.media-card-status.is-missing { + min-width: 1.45rem; + min-height: 1.45rem; + padding: 0.24rem 0.42rem; + justify-content: center; + gap: 0.28rem; + background: rgba(10, 14, 24, 0.52); + color: #ffd78a; + white-space: nowrap; +} + +.media-card-status.is-watched { + background: rgba(10, 14, 24, 0.58); + color: #8bf3ca; +} + +.media-card-status.is-watched .status-icon svg { + width: 1.12rem; + height: 1.12rem; +} + +.media-card-progress { + --watch-progress: 0%; + position: relative; + display: inline-grid; + place-items: center; + width: 1.96rem; + min-width: 1.96rem; + height: 1.96rem; + border-radius: 999px; + background: conic-gradient(#8bf3ca var(--watch-progress), rgba(255, 255, 255, 0.18) 0); + color: #f4f7fb; + font-size: 0.62rem; + font-weight: 800; +} + +.media-card-progress::before { + content: ""; + position: absolute; + inset: 0.25rem; + border-radius: inherit; + background: rgba(10, 14, 24, 0.82); +} + +.status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.media-card-status .status-warning-icon { + display: inline-flex; + color: #ffe5b5; +} + +.loading-spinner { + width: 1.12rem; + height: 1.12rem; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: currentColor; + animation: none; +} + +.loading-spinner.is-spinner-visible, +.player-loading-spinner { + animation: spin 0.85s linear infinite; +} + +.status-icon svg { + width: 0.95rem; + height: 0.95rem; + stroke-width: 2.2; +} + +.media-card-title { + font-weight: 700; +} + +.home-feature { + position: sticky; + top: 3.75rem; + z-index: 7; + isolation: isolate; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 1rem; + height: 350px; + min-height: 350px; + max-height: 350px; + padding: 1.4rem; + border-radius: 0; + overflow: hidden; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + /* Solid base color prevents item cards from bleeding through when scrolled underneath */ + background: #0d1221; +} + +.home-feature.has-artwork { + /* Keep a solid base so posters below can't bleed through */ + background: #0a0f1c; +} + +.home-feature.has-artwork::before { + content: ''; + position: absolute; + inset: 0 0 0 auto; + z-index: 0; + width: min(72%, 1040px); + background-image: var(--home-feature-image); + background-position: top right; + background-repeat: no-repeat; + background-size: cover; + pointer-events: none; + /* Only blend the left edge — right portion is fully visible, no mask applied there */ + mask-image: linear-gradient(to right, transparent 0%, black 18%); + -webkit-mask-image: linear-gradient(to right, transparent 0%, black 18%); +} + +.home-feature::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + /* Dark only on the left where text lives — fades to transparent before the artwork area */ + background: linear-gradient(90deg, rgba(8, 12, 20, 0.95) 0%, rgba(8, 12, 20, 0.72) 28%, rgba(8, 12, 20, 0.18) 52%, transparent 68%); + pointer-events: none; +} + +.home-page-backdrop .home-feature { + background: #0a0e1c; +} + +.home-page-backdrop .home-feature.has-artwork { + background: #090d1a; +} + +.home-page-backdrop .home-feature.has-artwork::before { + opacity: 0.62; +} + +.home-page-backdrop .home-feature::after { + background: linear-gradient(90deg, rgba(8, 12, 20, 0.9) 0%, rgba(8, 12, 20, 0.64) 28%, rgba(8, 12, 20, 0.12) 52%, transparent 68%); +} + +.home-feature-copy { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 760px; + min-width: 0; +} + +.home-feature-copy h2 { + margin: 0; + font-size: 2.35rem; + line-height: 1; +} + +.home-feature-copy p { + max-width: 68ch; + margin: 0; + color: #d7e4ff; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.home-feature-logo { + width: min(420px, 70%); + max-height: 135px; + object-fit: contain; + object-position: left center; +} + +.home-feature-action, +.home-feature-actions { + position: relative; + z-index: 2; + align-self: end; +} + +.home-feature-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + justify-content: flex-end; +} + +.search-results-section, +.search-results-list { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.search-popover { + position: absolute; + top: calc(100% + 0.45rem); + right: 0.8rem; + width: min(560px, calc(100vw - 1.6rem)); + max-height: min(560px, calc(100vh - 5rem)); + overflow: auto; + padding: 0.8rem; + border-radius: 10px; +} + +.search-popover-header { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0 0.2rem 0.65rem; + color: #dce7ff; +} + +.search-results-list.compact { + gap: 0.45rem; +} + +.search-results-list.compact .search-result-row { + grid-template-columns: 44px minmax(0, 1fr); +} + +.search-results-list.compact .search-result-thumb { + width: 44px; +} + +.search-result-row { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 0.85rem; + align-items: center; + padding: 0.65rem; + text-align: left; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + box-shadow: none; + color: inherit; +} + +.search-result-row:hover, +.search-result-row:focus-visible { + background: rgba(255, 255, 255, 0.08); +} + +.search-result-thumb { + width: 64px; + aspect-ratio: 2 / 3; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08) center / cover no-repeat; +} + +.search-result-copy { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.search-result-copy span, +.search-result-copy small { + color: #9ab1d1; +} + +.search-result-copy small { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.media-card-subtitle { + font-size: 0.82rem; + color: #d8e5ff; +} + +.detail-panel { + position: sticky; + top: 1.2rem; + align-self: start; + max-height: calc(100vh - 2.4rem); + overflow: auto; +} + +.panel-placeholder, +.empty-state { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + color: #afc0db; +} + +.empty-state.tight { + padding: 0.8rem; +} + +.detail-card { + display: flex; + flex-direction: column; + gap: 1.2rem; +} + +.detail-hero { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 1rem; + width: 100%; +} + +.detail-art { + aspect-ratio: 2 / 3; + border-radius: 20px; + display: grid; + place-items: center; + overflow: hidden; + background: linear-gradient(180deg, rgba(93, 123, 255, 0.9), rgba(27, 37, 62, 0.96)); + font-size: 2.2rem; + font-weight: 800; +} + +.detail-art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-summary { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + min-width: 0; +} + +.hero-tagline { + margin: 0; + font-size: 1.05rem; + color: #d6e5ff; +} + +.hero-meta-row { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.hero-description { + margin: 0.2rem 0 0; + max-width: 70ch; + color: #dbe7ff; +} + +.collapsible-text { + white-space: pre-line; + overflow-wrap: anywhere; +} + +.collapsible-text.is-collapsed { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 6; + overflow: hidden; +} + +.text-toggle-button { + align-self: flex-start; + margin-top: -0.1rem; + padding: 0; + min-height: auto; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: #9fc2ff; + font-weight: 700; +} + +.text-toggle-button:hover { + color: #ffffff; + background: transparent; + transform: none; +} + +.metadata-refresh-error { + display: block; + margin-top: 0.25rem; + color: #ffd0d0; +} + +.detail-summary h2, +.metadata-search-card strong { + margin: 0; +} + +.detail-actions { + display: flex; + gap: 0.7rem; + flex-wrap: wrap; +} + +.trailer-picker { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 0.9rem 1rem; + max-width: 760px; +} + +.trailer-picker-list { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.trailer-option-button { + text-align: left; +} + +.detail-section { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.metadata-search-list, +.item-page { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.item-page { + padding-top: 1rem; + padding-bottom: 1.2rem; +} + +.item-breadcrumbs { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + padding: 0.85rem 1rem; +} + +.breadcrumb-button { + padding: 0; + background: transparent; + box-shadow: none; + color: #b7cae6; +} + +.breadcrumb-button:hover { + color: #fff; +} + +.breadcrumb-separator, +.breadcrumb-current { + color: #86a0c7; +} + +.item-hero { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 1.5rem; + align-items: start; + min-height: min(58vh, 720px); + padding: 1.3rem 0 0.75rem; +} + +.item-hero.episode-hero { + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); +} + +.collection-hero { + min-height: min(48vh, 560px); +} + +.item-poster { + width: min(100%, 220px); + box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34); +} + +.collection-poster { + position: relative; +} + +.item-thumbnail { + width: min(100%, 360px); + aspect-ratio: 16 / 9; +} + +.item-summary { + align-self: start; + padding: 0.35rem 0 1rem; +} + +.item-summary h2, +.item-title-fallback { + font-size: 3.2rem; + line-height: 1.04; + margin-top: 0; +} + +.item-title-fallback { + max-width: min(780px, 100%); + overflow-wrap: anywhere; +} + +.item-fact-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.8rem; + margin-top: 0.5rem; +} + +.item-fact { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem 0.9rem; + border-radius: 18px; + background: rgba(8, 11, 18, 0.28); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); +} + +.item-support-grid { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.hierarchy-item-grid { + margin-top: 0.85rem; +} + +.hierarchy-item-grid.season-episodes-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.1rem; +} + +.hierarchy-item-grid.season-episodes-grid .episode-card { + gap: 0.55rem; +} + +.hierarchy-item-grid.season-episodes-grid .media-card-art.episode { + padding: 1rem; +} + +.person-credit-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + align-items: start; +} + +.person-credit-card, +.person-season-credit-card { + display: grid; + align-items: start; +} + +.person-credit-card.is-active, +.person-season-credit-card.is-active { + z-index: 1; +} + +.person-credit-tray { + display: none; + grid-column: 1 / -1; + padding: 0.9rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.055); + border: 1px solid rgba(255, 255, 255, 0.09); +} + +.person-credit-tray.is-active { + display: grid; + gap: 0.75rem; +} + +.person-credit-tray-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.78rem; + color: var(--muted); +} + +.person-credit-tray-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.8rem; + height: 1.8rem; + padding: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #dfe9ff; + box-shadow: none; +} + +.person-credit-tray-close:hover, +.person-credit-tray-close:focus-visible { + background: #d8ffe9; + color: #061018; +} + +.person-credit-tray-close svg { + width: 0.95rem; + height: 0.95rem; +} + +.person-season-credit-grid, +.person-episode-credit-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.8rem; + align-items: start; +} + +.person-episode-credit-grid { + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); +} + +.item-section { + padding: 1.1rem; +} + +.item-info-list { + display: grid; + gap: 0.9rem; +} + +.item-info-list > div { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.4rem; + padding: 0.65rem 1rem; + border-radius: 999px; + text-decoration: none; +} + +.people-groups { + display: grid; + gap: 1rem; +} + +.people-group h4 { + margin: 0 0 0.7rem; + font-size: 0.92rem; + color: var(--muted); +} + +.people-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 142px; + gap: 0.8rem; + overflow-x: auto; + padding-bottom: 0.2rem; + align-items: start; +} + +.extras-row { + grid-auto-columns: 244px; +} + +.media-extra-card { + display: flex; + flex-direction: column; + gap: 0.55rem; + align-items: stretch; + width: 244px; + padding: 0; + border-radius: 8px; + background: transparent; + box-shadow: none; + text-align: left; +} + +.media-extra-thumbnail { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 16 / 9; + border-radius: 8px; + overflow: hidden; + background: linear-gradient(135deg, rgba(57, 78, 123, 0.88), rgba(12, 18, 32, 0.94)); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #e7f0ff; +} + +.media-extra-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.media-extra-placeholder-icon svg { + width: 2rem; + height: 2rem; +} + +.media-extra-play-icon { + position: absolute; + right: 0.55rem; + bottom: 0.55rem; + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(5, 10, 18, 0.72); + color: #fff; +} + +.media-extra-title { + min-height: 2.5rem; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-weight: 700; + line-height: 1.25; +} + +.media-extra-meta { + display: flex; + justify-content: space-between; + gap: 0.65rem; + color: var(--muted); + font-size: 0.82rem; +} + +.media-extra-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.person-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: stretch; + width: 142px; + padding: 0; + border-radius: 12px; + background: transparent; + box-shadow: none; + text-align: left; +} + +.person-card-art { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 2 / 3; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + background-size: cover; + background-position: center; + overflow: hidden; + color: #dfe9ff; + font-size: 2.2rem; + font-weight: 700; +} + +.person-card-title, +.person-card-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.person-card-title { + font-weight: 700; +} + +.person-card-subtitle { + color: var(--muted); + font-size: 0.8rem; +} + +.person-poster { + max-width: 240px; +} + +.section-heading-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.metadata-search-list { + display: flex; + flex-direction: column; + gap: 0.7rem; + align-items: stretch; +} + +.metadata-search-card { + display: flex; + justify-content: flex-start; + gap: 1rem; + align-items: start; + width: 100%; + padding: 0.9rem 1rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.metadata-search-poster { + width: 56px; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 6px; + flex: 0 0 auto; + background: rgba(255, 255, 255, 0.06); +} + +.metadata-search-card p { + margin: 0.35rem 0 0; +} + +.metadata-match-meta { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.metadata-attribution-logo { + max-width: 76px; + max-height: 18px; + object-fit: contain; +} + +.metadata-current-link { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 0.9rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.metadata-current-copy { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.metadata-attribution { + color: var(--muted); + font-size: 0.82rem; + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.metadata-attribution img { + max-width: 36px; + max-height: 36px; + object-fit: contain; +} + +.metadata-search-card > div { + flex: 1; + min-width: 0; +} + +.metadata-search-card > button { + margin-left: auto; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.55rem; + border-radius: 999px; + font-size: 0.76rem; + background: rgba(255, 255, 255, 0.07); +} + +.tag.success { + background: rgba(66, 214, 158, 0.18); + color: #8bf3ca; +} + +.tag.warning { + background: rgba(255, 191, 84, 0.16); + color: #ffd78a; +} + +.tag.status-tag { + gap: 0.35rem; +} + +.metadata-search-form, +.settings-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.metadata-provider-picker { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + +.item-title-logo { + display: block; + max-width: min(340px, 100%); + max-height: 120px; + object-fit: contain; + object-position: left center; +} + +.settings-activity-panel, +.metadata-dashboard-panel, +.settings-log-panel { + padding: 1.2rem; +} + +.metadata-dashboard-filter-grid { + align-items: end; +} + +.table-shell { + width: 100%; + overflow-x: auto; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 780px; +} + +.data-table th, +.data-table td { + text-align: left; + vertical-align: top; + padding: 0.7rem 0.8rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.02); +} + +.table-title-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.metadata-dashboard-path, +.metadata-dashboard-error { + margin: 0; +} + +.metadata-dashboard-error { + color: #ffd0d0; +} + +.settings-system-activity-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.settings-system-activity { + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.settings-system-activity-header { + display: flex; + justify-content: space-between; + gap: 0.9rem; + align-items: start; +} + +.activity-progress-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.activity-progress-bar { + position: relative; + flex: 1; + min-width: 0; + height: 0.6rem; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); +} + +.activity-progress-fill { + display: block; + width: var(--activity-progress, 0%); + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #5d7bff 0%, #8bf3ca 100%); +} + +.log-filter-form { + margin-bottom: 1rem; +} + +.log-entries-table .log-entry-message { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: inherit; + background: transparent; + border: none; + padding: 0; + font-size: 0.84rem; +} + +.log-filter-row { + align-items: end; +} + +.log-entry-source, +.log-entry-message { + margin: 0; +} + +.log-entry-message { + white-space: pre-wrap; + word-break: break-word; + font-family: 'Cascadia Mono', 'Fira Code', Consolas, monospace; + font-size: 0.86rem; + line-height: 1.5; + color: #dbe8ff; +} + +@media (max-width: 1320px) { + .page-backdrop::before { + inset: 0; + width: auto; + height: auto; + background-image: var(--page-backdrop-image, none); + background-position: center top; + opacity: 0.42; + transform: none; + filter: saturate(1.02) contrast(1.02); + mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%); + -webkit-mask-image: radial-gradient(circle at center 20%, rgba(0, 0, 0, 0.84) 0%, rgba(0, 0, 0, 0.68) 40%, rgba(0, 0, 0, 0.34) 66%, transparent 100%); + } + + .page-backdrop::after { + background: + radial-gradient(circle at center 16%, rgba(12, 17, 29, 0) 0%, rgba(12, 17, 29, 0.24) 30%, rgba(12, 17, 29, 0.7) 72%, #0c111d 100%), + linear-gradient(180deg, rgba(12, 17, 29, 0.12) 0%, rgba(12, 17, 29, 0.28) 32%, rgba(12, 17, 29, 0.72) 64%, #0c111d 100%); + } + + .item-hero, + .item-support-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.settings-drawer { + position: fixed; + top: 0; + right: 0; + width: min(520px, 100vw); + height: 100vh; + padding: 1.2rem; + overflow: auto; + border-left: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 13, 24, 0.96); + backdrop-filter: blur(20px); + z-index: 20; +} + +.settings-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + margin-bottom: 1rem; +} + +.settings-form section { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.04); +} + +.settings-page-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.settings-library-list { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.user-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.user-edit-row { + align-items: end; +} + +.user-edit-fields { + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(9rem, 0.7fr) minmax(14rem, 1.4fr) auto; + gap: 0.75rem; + align-items: end; + flex: 1; +} + +.settings-library-card { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.settings-library-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.settings-library-actions { + display: flex; + gap: 0.65rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.settings-library-tags { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.45rem; +} + +.danger-button { + background: rgba(255, 107, 107, 0.14); + color: #ffb9b9; +} + +.page-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.settings-form label { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.checkbox-row, +.checkbox-inline { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + align-items: center; +} + +.checkbox-inline { + flex-direction: row; +} + +.checkbox-inline input, +.checkbox-row input { + width: auto; +} + +.weekday-toggle-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.add-library-form { + margin-top: 1rem; +} + +.metadata-provider-list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.metadata-provider-option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + padding: 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.metadata-provider-option.is-disabled { + opacity: 0.58; +} + +.provider-option-main, +.provider-option-actions { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} + +.provider-settings-title { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.provider-settings-logo { + width: 2.75rem; + height: 2.75rem; + object-fit: contain; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + padding: 0.35rem; +} + +.player-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 0; + background: #000; + z-index: 30; +} + +.app-shell > .player-overlay { + position: fixed; + z-index: 30; +} + +.trailer-overlay { + padding: 0; + background: #000; +} + +.player-shell.trailer-shell { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + padding: 0; + gap: 0; + border: 0; + border-radius: 0; + background: #000; + overflow: hidden; + outline: none; +} + +.trailer-frame-shell { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 0; + overflow: hidden; + background: #000; +} + +.trailer-youtube-player, +#trailer-player, +.trailer-frame-shell iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + display: block; +} + +.trailer-frame-shell iframe { + pointer-events: none; +} + +.trailer-unavailable { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 2rem; + color: #dbe6f7; + text-align: center; +} + +.trailer-youtube-chrome-mask { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + opacity: 1; + transition: opacity 180ms ease; +} + +.trailer-youtube-chrome-mask::before, +.trailer-youtube-chrome-mask::after { + content: ""; + position: absolute; + left: 0; + right: 0; + background: #000; +} + +.trailer-youtube-chrome-mask::before { + top: 0; + height: clamp(90px, 18vh, 90px); +} + +.trailer-youtube-chrome-mask::after { + bottom: 0; + height: clamp(90px, 24vh, 90px); +} + +.trailer-shell.is-controls-hidden .trailer-youtube-chrome-mask { + opacity: 0; +} + +.trailer-shell .player-top-actions .button-link { + background: rgba(18, 18, 20, 0.78); + border-color: rgba(255, 255, 255, 0.14); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36); + backdrop-filter: blur(18px); +} + +.theme-song-iframe { + position: fixed; + right: -8px; + bottom: -8px; + width: 1px; + height: 1px; + border: 0; + opacity: 0.01; + pointer-events: none; +} + +.danger-tag { + color: #ffd0d0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.player-shell { + width: min(960px, 100%); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(11, 16, 27, 0.96); +} + +.media-player-shell { + position: relative; + width: 100vw; + height: 100vh; + padding: 0; + gap: 0; + border: 0; + border-radius: 0; + background: #000; + overflow: hidden; + outline: none; +} + +.media-player-shell video, +.media-player-shell audio { + width: 100%; + height: 100%; +} + +.media-player-shell video { + display: block; + object-fit: contain; + background: #000; +} + +.media-player-shell audio { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.audio-player-shell { + display: grid; + place-items: center; + background: + linear-gradient(180deg, rgba(4, 8, 14, 0.26), rgba(4, 8, 14, 0.9)), + #05080f; +} + +.audio-player-backdrop { + position: absolute; + inset: -3rem; + background-image: var(--player-backdrop-image); + background-position: center; + background-size: cover; + filter: blur(36px) saturate(1.18); + opacity: 0.34; + transform: scale(1.04); +} + +.audio-player-art { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: min(42vh, 360px, 72vw); + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + background: linear-gradient(145deg, rgba(66, 214, 158, 0.8), rgba(93, 123, 255, 0.72)); + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.5); +} + +.audio-player-art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.audio-player-art-icon svg { + width: 5rem; + height: 5rem; + stroke-width: 1.4; +} + +.player-idle-hit-area { + position: absolute; + inset: 0; + z-index: 2; +} + +.player-loading-indicator { + position: absolute; + inset: 0; + z-index: 2; + display: none; + place-items: center; + pointer-events: none; +} + +.is-media-loading .player-loading-indicator { + display: grid; +} + +.player-error-indicator { + position: absolute; + inset: 0; + z-index: 2; + display: none; + place-items: center; + gap: 0.25rem; + pointer-events: none; + text-align: center; + color: #f4f7fb; +} + +.player-error-indicator span { + color: #b8c7df; +} + +.has-media-error .player-error-indicator { + display: grid; +} + +.player-loading-spinner { + width: 3.2rem; + height: 3.2rem; + color: #8bf3ca; + border-width: 3px; + filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45)); +} + +.player-controls { + position: absolute; + z-index: 3; + transition: opacity 180ms ease, transform 180ms ease; +} + +.is-controls-hidden .player-controls { + opacity: 0; + pointer-events: none; +} + +.player-top-controls { + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) 5rem max(1rem, env(safe-area-inset-left)); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.76), rgba(0, 0, 0, 0)); +} + +.is-controls-hidden .player-top-controls { + transform: translateY(-0.75rem); +} + +.player-title-block { + min-width: 0; +} + +.player-title-block h2 { + margin: 0.1rem 0 0; + font-size: clamp(1.1rem, 2.3vw, 2rem); + line-height: 1.1; + overflow-wrap: anywhere; +} + +.player-title-logo { + display: block; + max-width: min(360px, 52vw); + max-height: 70px; + object-fit: contain; + object-position: left center; +} + +.trailer-title-brand-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.9rem; + margin-top: 0.2rem; +} + +.trailer-title-brand-row h2 { + margin: 0; +} + +.trailer-title-logo { + flex: 0 1 auto; + max-width: min(280px, 34vw); + max-height: 58px; +} + +.player-top-actions, +.player-control-row, +.player-control-cluster { + display: flex; + align-items: center; +} + +.player-top-actions, +.player-control-cluster { + gap: 0.6rem; +} + +.player-menu-shell { + position: relative; +} + +.player-track-menu { + position: absolute; + right: 0; + bottom: calc(100% + 0.65rem); + display: flex; + flex-direction: column; + min-width: 220px; + max-width: min(320px, calc(100vw - 2rem)); + max-height: min(320px, 50vh); + overflow: auto; + padding: 0.35rem; + border-radius: 8px; + background: rgba(10, 14, 24, 0.96); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(18px); +} + +.player-track-menu.is-hidden, +.player-track-menu[hidden] { + display: none; +} + +.player-track-option { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.12rem; + width: 100%; + padding: 0.65rem 0.7rem; + border-radius: 6px; + background: transparent; + box-shadow: none; + text-align: left; + color: #f4f7fb; +} + +.player-track-option:hover, +.player-track-option.active { + background: rgba(255, 255, 255, 0.12); +} + +.player-track-option small { + color: #9ab1d1; +} + +.player-badge { + display: inline-flex; + align-items: center; + min-height: 2.25rem; + padding: 0 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: #f4f7fb; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; + backdrop-filter: blur(16px); +} + +.player-badge.is-direct { + color: #aef8d4; +} + +.player-badge.is-transcoding { + color: #ffdf9b; +} + +.player-center-controls { + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 1.1rem; + pointer-events: none; +} + +.player-center-controls .player-icon-button { + pointer-events: auto; +} + +.is-controls-hidden .player-center-controls { + transform: scale(0.98); +} + +.player-bottom-controls { + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 5rem max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); + background: linear-gradient(0deg, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0)); +} + +.is-controls-hidden .player-bottom-controls { + transform: translateY(0.75rem); +} + +.player-control-row { + display: grid; + grid-template-columns: minmax(7.5rem, 1fr) auto minmax(7.5rem, 1fr); + gap: 1rem; + width: 100%; +} + +.player-transport-cluster { + justify-content: center; +} + +.player-tool-cluster { + justify-content: flex-end; +} + +.player-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.55rem; + height: 2.55rem; + padding: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.13); + box-shadow: none; + color: #fff; + backdrop-filter: blur(14px); +} + +.player-icon-button:hover:not(:disabled), +.player-icon-button:focus-visible { + background: rgba(255, 255, 255, 0.24); + box-shadow: none; +} + +.player-large-button { + width: 3.2rem; + height: 3.2rem; +} + +.player-primary-button { + width: 4.2rem; + height: 4.2rem; + background: rgba(255, 255, 255, 0.22); +} + +.player-control-icon svg { + display: block; + width: 1.25rem; + height: 1.25rem; + stroke-width: 2.2; +} + +.player-primary-button .player-control-icon svg { + width: 1.7rem; + height: 1.7rem; +} + +.player-time { + display: inline-flex; + gap: 0.35rem; + min-width: 7.5rem; + color: #eaf1ff; + font-variant-numeric: tabular-nums; +} + +.player-progress, +.player-volume { + accent-color: #8bf3ca; + cursor: pointer; +} + +.player-progress { + width: 100%; +} + +.player-volume { + width: min(10vw, 110px); + min-width: 72px; +} + +.media-player-shell.is-picture-in-picture { + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.media-player-overlay:has(.is-picture-in-picture) { + pointer-events: none; + background: transparent; +} + +.player-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.player-header-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + justify-content: flex-end; +} + +#theme-song-player { + display: none; +} + +@media (max-width: 1280px) { + .workspace-grid { + grid-template-columns: minmax(0, 1fr); + } + + .detail-panel { + position: static; + max-height: none; + } +} + +@media (max-width: 960px) { + .app-shell { + grid-template-columns: 1fr; + height: auto; + min-height: 100vh; + overflow: visible; + } + + .library-rail { + grid-column: auto; + grid-row: auto; + height: auto; + max-width: none; + flex-direction: row; + align-items: center; + overflow: auto; + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + + .library-rail-top, + .library-rail-bottom { + flex-direction: row; + align-items: center; + min-height: auto; + } + + .rail-nav { + flex-direction: row; + overflow: visible; + } + + .rail-button { + min-width: 110px; + } + + .main-shell { + grid-column: auto; + grid-row: auto; + height: auto; + overflow: visible; + padding: 0.9rem; + } + + .home-navbar { + flex-wrap: wrap; + align-items: stretch; + padding: 0.45rem; + } + + .home-navbar-tools, + .home-navbar .search-form { + width: 100%; + } + + .home-navbar .search-form { + flex-direction: row; + } + + .home-feature { + top: 5.6rem; + height: 230px; + min-height: 230px; + max-height: 230px; + } + + .home-feature.has-artwork::before { + width: 72%; + } + + .content-navbar, + .library-overview-header, + .section-heading-actions, + .settings-library-header, + .player-header { + flex-direction: column; + align-items: stretch; + } + + .search-form, + .form-row, + .item-hero, + .item-support-grid { + grid-template-columns: 1fr; + min-width: 0; + } + + .content-navbar-actions-stack { + flex-direction: column; + } + + .metadata-dashboard-row, + .metadata-dashboard-title-row, + .activity-progress-row, + .settings-system-activity-header, + .log-entry-header { + flex-direction: column; + align-items: stretch; + } + + .item-poster { + width: min(220px, 100%); + } + + .player-control-row { + grid-template-columns: 1fr; + justify-items: center; + } + + .player-time-cluster, + .player-transport-cluster, + .player-tool-cluster { + justify-content: center; + } + + .player-tool-cluster { + flex-wrap: wrap; + } +} diff --git a/crates/client-web/tsconfig.json b/crates/client-web/tsconfig.json new file mode 100644 index 00000000..3d74bd5f --- /dev/null +++ b/crates/client-web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/crates/client-web/vite.config.ts b/crates/client-web/vite.config.ts new file mode 100644 index 00000000..f13a07fd --- /dev/null +++ b/crates/client-web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: '127.0.0.1', + port: 4173, + }, + preview: { + host: '127.0.0.1', + port: 4173, + }, +}); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d54d0dc7..07c0b8a7 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -43,6 +43,19 @@ path = "src/main.rs" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] } +[features] +default = [ + "native-secret-store", + "tray", +] +native-secret-store = ["dep:keyring"] +tray = [ + "dep:objc2-core-foundation", + "dep:tao", + "dep:tray-icon", + "dep:webbrowser", +] + [dependencies] # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses base64 = "0.22.1" @@ -55,35 +68,37 @@ diesel_migrations = "2.2.0" dirs = "6.0.0" fern = { version = "0.7.1", features = ["colored"] } image = "0.25.5" +imohash = "0.1.2" jsonwebtoken = "9.3.1" +keyring = { version = "4.0.0", optional = true } +keyring-core = { version = "1.0.0", features = ["sample"] } libsqlite3-sys = { version = "0.35", features = ["bundled"] } # this is needed for proper linking log = "0.4.25" once_cell = "1.20.3" rand = "0.9.0" rcgen = "0.13.2" regex = "1.11.1" +reqwest = { version = "0.13.2", default-features = false, features = ["json", "query", "rustls"] } rocket = { version = "0.5.1", features = ["tls"] } rocket_okapi = { version = "0.9.0", features = ["swagger", "rapidoc"] } rocket_sync_db_pools = { version = "0.1.0", features = ["diesel_sqlite_pool"] } schemars = "0.8.1" serde = "1.0.217" serde_json = "1.0.138" -tao = "0.35.0" +serde_yaml = "0.9.34" +sha2 = "0.11.0" +strsim = "0.11.1" +tao = { version = "0.35.0", optional = true } tokio = { version = "1.0", features = ["full"] } -tray-icon = "0.22.0" -webbrowser = "1.0.3" +tmdb_client = "1.8.0" +tray-icon = { version = "0.22.0", optional = true } +tvdb4 = "0.1.0" +webbrowser = { version = "1.0.3", optional = true } # common = { path = "../common" } [target.'cfg(target_os = "macos")'.dependencies] -objc2-core-foundation = "0.3.0" +objc2-core-foundation = { version = "0.3.0", optional = true } [dev-dependencies] async-std.workspace = true rstest.workspace = true - -[package.metadata.ci] -cargo-run-bin = "1.7.4" - -[package.metadata.bin] -cargo-edit = { version = "0.13.1" } -cargo-tarpaulin = { version = "0.31.5" } diff --git a/crates/server/sql/migrations/0_create_users/down.sql b/crates/server/sql/migrations/0_create_users/down.sql deleted file mode 100644 index c99ddcdc..00000000 --- a/crates/server/sql/migrations/0_create_users/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS users; diff --git a/crates/server/sql/migrations/0_create_users/up.sql b/crates/server/sql/migrations/0_create_users/up.sql deleted file mode 100644 index 590d989d..00000000 --- a/crates/server/sql/migrations/0_create_users/up.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - pin TEXT DEFAULT NULL, - admin BOOLEAN NOT NULL DEFAULT FALSE -); diff --git a/crates/server/sql/migrations/a54d52c8da5e_initial_schema/down.sql b/crates/server/sql/migrations/a54d52c8da5e_initial_schema/down.sql new file mode 100644 index 00000000..17ac4023 --- /dev/null +++ b/crates/server/sql/migrations/a54d52c8da5e_initial_schema/down.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys = OFF; + +DROP TABLE IF EXISTS metadata_extras; +DROP TABLE IF EXISTS external_media; +DROP TABLE IF EXISTS metadata_collection_items; +DROP TABLE IF EXISTS metadata_collections; +DROP TABLE IF EXISTS metadata_person_external_ids; +DROP TABLE IF EXISTS metadata_person_credits; +DROP TABLE IF EXISTS metadata_people; +DROP TABLE IF EXISTS item_metadata_people; +DROP TABLE IF EXISTS item_metadata_external_ids; +DROP TABLE IF EXISTS item_metadata_links; +DROP TABLE IF EXISTS playback_progress; +DROP TABLE IF EXISTS media_file_libraries; +DROP TABLE IF EXISTS media_files; +DROP TABLE IF EXISTS media_items; +DROP TABLE IF EXISTS scan_state; +DROP TABLE IF EXISTS media_libraries; +DROP TABLE IF EXISTS app_settings; +DROP TABLE IF EXISTS users; + +PRAGMA foreign_keys = ON; diff --git a/crates/server/sql/migrations/a54d52c8da5e_initial_schema/up.sql b/crates/server/sql/migrations/a54d52c8da5e_initial_schema/up.sql new file mode 100644 index 00000000..482ce7f8 --- /dev/null +++ b/crates/server/sql/migrations/a54d52c8da5e_initial_schema/up.sql @@ -0,0 +1,349 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + pin TEXT DEFAULT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE, + birthday TEXT DEFAULT NULL, + profile_image_path TEXT DEFAULT NULL, + preferred_metadata_languages_json TEXT NOT NULL DEFAULT '["en-US"]' +); + +CREATE TABLE media_libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL, + paths_json TEXT NOT NULL DEFAULT '[]', + kind TEXT NOT NULL, + scanner TEXT NOT NULL DEFAULT 'auto', + recursive BOOLEAN NOT NULL DEFAULT TRUE, + metadata_providers_json TEXT NOT NULL DEFAULT '["tmdb"]', + metadata_language_mode TEXT NOT NULL DEFAULT 'auto', + metadata_languages_json TEXT NOT NULL DEFAULT '["en-US"]', + allowed_user_ids_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE scan_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL UNIQUE, + last_status TEXT NOT NULL DEFAULT 'never_scanned', + last_error TEXT DEFAULT NULL, + scan_revision BIGINT NOT NULL DEFAULT 0, + last_scanned_at BIGINT DEFAULT NULL, + total_files BIGINT NOT NULL DEFAULT 0, + video_files BIGINT NOT NULL DEFAULT 0, + audio_files BIGINT NOT NULL DEFAULT 0, + image_files BIGINT NOT NULL DEFAULT 0, + book_files BIGINT NOT NULL DEFAULT 0, + other_files BIGINT NOT NULL DEFAULT 0, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE +); + +CREATE TABLE media_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + parent_id INTEGER DEFAULT NULL, + identity_key TEXT NOT NULL UNIQUE, + item_type TEXT NOT NULL, + display_title TEXT NOT NULL, + relative_path TEXT DEFAULT NULL, + media_kind TEXT DEFAULT NULL, + season_number INTEGER DEFAULT NULL, + episode_number INTEGER DEFAULT NULL, + child_count INTEGER NOT NULL DEFAULT 0, + playable BOOLEAN NOT NULL DEFAULT FALSE, + file_size BIGINT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + modified_at BIGINT DEFAULT NULL, + created_at BIGINT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + missing_since BIGINT DEFAULT NULL, + deleted_at BIGINT DEFAULT NULL, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE TABLE media_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + file_size BIGINT NOT NULL, + modified_at BIGINT DEFAULT NULL, + media_kind TEXT NOT NULL, + file_hash TEXT NOT NULL DEFAULT '', + container TEXT DEFAULT NULL, + duration_ms BIGINT DEFAULT NULL, + bit_rate BIGINT DEFAULT NULL, + width INTEGER DEFAULT NULL, + height INTEGER DEFAULT NULL, + video_codec TEXT DEFAULT NULL, + audio_codec TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_updated_at BIGINT DEFAULT NULL, + UNIQUE (path) +); + +CREATE TABLE media_file_libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_file_id INTEGER NOT NULL, + library_id INTEGER NOT NULL, + source_root_path TEXT NOT NULL, + relative_path TEXT NOT NULL, + display_title TEXT DEFAULT NULL, + metadata_match_attempted_at BIGINT DEFAULT NULL, + media_item_id INTEGER DEFAULT NULL, + missing_since BIGINT DEFAULT NULL, + deleted_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE SET NULL, + UNIQUE (library_id, source_root_path, relative_path) +); + +CREATE TABLE item_metadata_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_item_id INTEGER NOT NULL, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + tagline TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + release_year INTEGER DEFAULT NULL, + media_type TEXT DEFAULT NULL, + relation_kind TEXT NOT NULL DEFAULT 'primary', + match_state TEXT NOT NULL DEFAULT 'unmatched', + logo_url TEXT DEFAULT NULL, + cached_logo_path TEXT DEFAULT NULL, + genres_json TEXT DEFAULT NULL, + rating FLOAT DEFAULT NULL, + content_rating TEXT DEFAULT NULL, + locale_key TEXT NOT NULL DEFAULT 'en-US', + provider_locale_key TEXT DEFAULT NULL, + cached_artwork_path TEXT DEFAULT NULL, + cached_backdrop_path TEXT DEFAULT NULL, + refresh_state TEXT NOT NULL DEFAULT 'fresh', + refresh_interval_seconds BIGINT NOT NULL DEFAULT 604800, + last_refreshed_at BIGINT DEFAULT NULL, + next_refresh_at BIGINT DEFAULT NULL, + refresh_error TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE, + UNIQUE (media_item_id, provider_id, relation_kind, locale_key) +); + +CREATE TABLE item_metadata_external_ids ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER NOT NULL, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE, + UNIQUE (metadata_link_id, source) +); + +CREATE TABLE item_metadata_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER NOT NULL, + external_id TEXT DEFAULT NULL, + name TEXT NOT NULL, + role TEXT DEFAULT NULL, + department TEXT DEFAULT NULL, + character_name TEXT DEFAULT NULL, + profile_url TEXT DEFAULT NULL, + image_url TEXT DEFAULT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE +); + +CREATE TABLE metadata_people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + external_id TEXT DEFAULT NULL, + locale_key TEXT NOT NULL DEFAULT 'en-US', + name TEXT NOT NULL, + known_for_json TEXT DEFAULT NULL, + biography TEXT DEFAULT NULL, + gender TEXT DEFAULT NULL, + birthday TEXT DEFAULT NULL, + deathday TEXT DEFAULT NULL, + birth_place TEXT DEFAULT NULL, + profile_url TEXT DEFAULT NULL, + image_url TEXT DEFAULT NULL, + cached_image_path TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL +); + +CREATE TABLE metadata_person_credits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER NOT NULL, + person_id INTEGER NOT NULL, + role TEXT DEFAULT NULL, + department TEXT DEFAULT NULL, + character_name TEXT DEFAULT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE, + FOREIGN KEY (person_id) REFERENCES metadata_people(id) ON DELETE CASCADE, + UNIQUE (metadata_link_id, person_id, role, character_name) +); + +CREATE TABLE metadata_person_external_ids ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (person_id) REFERENCES metadata_people(id) ON DELETE CASCADE, + UNIQUE (person_id, source) +); + +CREATE TABLE metadata_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + external_id TEXT NOT NULL, + source_provider_id TEXT NOT NULL, + source_external_id TEXT NOT NULL, + relation_kind TEXT NOT NULL, + locale_key TEXT NOT NULL, + provider_locale_key TEXT DEFAULT NULL, + name TEXT DEFAULT NULL, + overview TEXT DEFAULT NULL, + artwork_url TEXT DEFAULT NULL, + backdrop_url TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + UNIQUE (provider_id, external_id, relation_kind, locale_key) +); + +CREATE TABLE metadata_collection_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL, + media_item_id INTEGER NOT NULL, + metadata_link_id INTEGER NOT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (collection_id) REFERENCES metadata_collections(id) ON DELETE CASCADE, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE, + UNIQUE (collection_id, media_item_id) +); + +CREATE TABLE external_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + external_id TEXT DEFAULT NULL, + url TEXT NOT NULL, + media_kind TEXT NOT NULL DEFAULT 'video', + title TEXT DEFAULT NULL, + duration_seconds INTEGER DEFAULT NULL, + thumbnail_url TEXT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + UNIQUE (url) +); + +CREATE TABLE metadata_extras ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metadata_link_id INTEGER DEFAULT NULL, + collection_id INTEGER DEFAULT NULL, + external_media_id INTEGER NOT NULL, + extra_type TEXT NOT NULL, + title TEXT DEFAULT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (metadata_link_id) REFERENCES item_metadata_links(id) ON DELETE CASCADE, + FOREIGN KEY (collection_id) REFERENCES metadata_collections(id) ON DELETE CASCADE, + FOREIGN KEY (external_media_id) REFERENCES external_media(id) ON DELETE CASCADE, + CHECK ( + (metadata_link_id IS NOT NULL AND collection_id IS NULL) + OR (metadata_link_id IS NULL AND collection_id IS NOT NULL) + ), + UNIQUE (metadata_link_id, extra_type, external_media_id), + UNIQUE (collection_id, extra_type, external_media_id) +); + +CREATE TABLE playback_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT NULL, + media_item_id INTEGER NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT DEFAULT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + watch_count INTEGER NOT NULL DEFAULT 0, + last_watched_at BIGINT DEFAULT NULL, + updated_at BIGINT DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (media_item_id) REFERENCES media_items(id) ON DELETE CASCADE, + UNIQUE (user_id, media_item_id) +); + +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + updated_at BIGINT DEFAULT NULL +); + +CREATE INDEX idx_media_items_library_parent ON media_items (library_id, parent_id); +CREATE INDEX idx_media_items_identity_key ON media_items (identity_key); +CREATE INDEX idx_media_items_missing_since ON media_items (missing_since); +CREATE INDEX idx_media_items_deleted_at ON media_items (deleted_at); + +CREATE INDEX idx_media_file_libraries_media_file_id + ON media_file_libraries (media_file_id); +CREATE INDEX idx_media_file_libraries_library_id + ON media_file_libraries (library_id); +CREATE INDEX idx_media_file_libraries_media_item_id + ON media_file_libraries (media_item_id); +CREATE INDEX idx_media_file_libraries_missing_since + ON media_file_libraries (missing_since); +CREATE INDEX idx_media_file_libraries_deleted_at + ON media_file_libraries (deleted_at); + +CREATE INDEX idx_item_metadata_links_media_item_id + ON item_metadata_links (media_item_id); +CREATE INDEX idx_item_metadata_external_ids_link_id + ON item_metadata_external_ids (metadata_link_id); +CREATE INDEX idx_item_metadata_external_ids_source_external_id + ON item_metadata_external_ids (source, external_id); +CREATE INDEX idx_item_metadata_people_link_id + ON item_metadata_people (metadata_link_id); + +CREATE UNIQUE INDEX idx_metadata_people_provider_external_locale + ON metadata_people (provider_id, external_id, locale_key) + WHERE external_id IS NOT NULL; +CREATE UNIQUE INDEX idx_metadata_people_provider_name_locale_without_external + ON metadata_people (provider_id, lower(name), locale_key) + WHERE external_id IS NULL; +CREATE INDEX idx_metadata_people_provider_external + ON metadata_people (provider_id, external_id); +CREATE INDEX idx_metadata_people_provider_locale + ON metadata_people (provider_id, locale_key); + +CREATE INDEX idx_metadata_person_credits_person_id + ON metadata_person_credits (person_id); +CREATE INDEX idx_metadata_person_credits_link_id + ON metadata_person_credits (metadata_link_id); +CREATE INDEX idx_metadata_person_external_ids_person_id + ON metadata_person_external_ids (person_id); +CREATE INDEX idx_metadata_person_external_ids_source_external_id + ON metadata_person_external_ids (source, external_id); + +CREATE INDEX idx_metadata_collection_items_collection_id + ON metadata_collection_items (collection_id); +CREATE INDEX idx_metadata_collection_items_media_item_id + ON metadata_collection_items (media_item_id); +CREATE INDEX idx_metadata_collection_items_metadata_link_id + ON metadata_collection_items (metadata_link_id); + +CREATE INDEX idx_external_media_source_external_id + ON external_media (source, external_id); +CREATE INDEX idx_metadata_extras_metadata_link_id + ON metadata_extras (metadata_link_id); +CREATE INDEX idx_metadata_extras_collection_id + ON metadata_extras (collection_id); +CREATE INDEX idx_metadata_extras_external_media_id + ON metadata_extras (external_media_id); +CREATE INDEX idx_metadata_extras_extra_type + ON metadata_extras (extra_type); + +CREATE INDEX idx_playback_progress_media_item_id + ON playback_progress (media_item_id); + +PRAGMA foreign_keys = ON; diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 8109f091..bae2c1d8 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -1,17 +1,44 @@ //! Authentication utilities for the application. // lib imports -use base64::{Engine as _, engine::general_purpose}; -use bcrypt::{DEFAULT_COST, hash, verify}; -use diesel::{QueryDsl, RunQueryDsl}; -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use base64::{ + Engine as _, + engine::general_purpose, +}; +use bcrypt::{ + DEFAULT_COST, + hash, + verify, +}; +use diesel::{ + QueryDsl, + RunQueryDsl, +}; +use jsonwebtoken::{ + DecodingKey, + EncodingKey, + Header, + Validation, + decode, + encode, +}; use once_cell::sync::Lazy; use rand::Rng; use rocket::http::Status; use rocket::outcome::Outcome; -use rocket::request::{self, FromRequest, Request}; -use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; -use serde::{Deserialize, Serialize}; +use rocket::request::{ + self, + FromRequest, + Request, +}; +use rocket_okapi::request::{ + OpenApiFromRequest, + RequestHeaderInput, +}; +use serde::{ + Deserialize, + Serialize, +}; // local imports use crate::db::DbConn; @@ -112,7 +139,11 @@ impl<'r, const ROLE: u8> FromRequest<'r> for AuthGuard { /// Helper function to create Bearer token security configuration for OpenAPI fn create_bearer_auth_security(scopes: Vec) -> rocket_okapi::Result { use rocket_okapi::okapi::Map; - use rocket_okapi::okapi::openapi3::{SecurityRequirement, SecurityScheme, SecuritySchemeData}; + use rocket_okapi::okapi::openapi3::{ + SecurityRequirement, + SecurityScheme, + SecuritySchemeData, + }; let security_scheme = SecurityScheme { data: SecuritySchemeData::Http { diff --git a/crates/server/src/certs.rs b/crates/server/src/certs.rs index cba2869d..50b7db2a 100644 --- a/crates/server/src/certs.rs +++ b/crates/server/src/certs.rs @@ -5,7 +5,10 @@ use std::fs; use std::path::Path; // lib imports -use rcgen::{CertifiedKey, generate_simple_self_signed}; +use rcgen::{ + CertifiedKey, + generate_simple_self_signed, +}; /// Ensure that the certificates exist at the given paths. pub fn ensure_certificates_exist( diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 621f7148..3b587ad0 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -1,24 +1,655 @@ //! Configuration module for the application. +// standard imports +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::RwLock; + // lib imports -use config::{Config, ConfigError, Environment, File}; +use config::{ + Config, + ConfigError, + Environment, + File, +}; +use diesel::prelude::*; use dirs::config_local_dir; use once_cell::sync::Lazy; +use schemars::JsonSchema; use serde::Deserialize; +use serde::Serialize; // local imports +use crate::db::models::AppSetting; +use crate::db::schema::app_settings; use crate::globals::GLOBAL_APP_NAME; +const METADATA_SETTINGS_KEY: &str = "metadata"; +const MEDIA_SETTINGS_KEY: &str = "media"; +const SERVER_SETTINGS_KEY: &str = "server"; +const FFMPEG_SETTINGS_KEY: &str = "ffmpeg"; +const SCHEDULED_TASKS_SETTINGS_KEY: &str = "scheduled_tasks"; + /// General settings. -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] pub struct GeneralSettings { /// The directory where application data is stored. #[serde(default)] pub data_dir: String, } +/// Supported library categories for configured media roots. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MediaLibraryKind { + /// Mixed content when the library is not limited to a single media type. + #[default] + Mixed, + /// Feature films and similar long-form video content. + Movies, + /// Episodic television or serialized video content. + Shows, + /// Music, albums, and other audio-focused content. + Music, + /// Photos and other image collections. + Photos, + /// Books, comics, PDFs, and other reading material. + Books, + /// Home videos and other personal recordings. + HomeVideos, +} + +/// Scanner implementation used to inventory a media library. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MediaLibraryScanner { + /// Choose the scanner that matches the library type. + #[default] + Auto, + /// Generic directory scanner. + Directory, + /// Movie scanner. + Movies, + /// TV show scanner. + Shows, + /// Music scanner. + Music, + /// Photo scanner. + Photos, + /// Book scanner. + Books, +} + +impl MediaLibraryScanner { + /// Return the concrete scanner used for the given library kind. + pub fn effective_for_kind( + &self, + kind: &MediaLibraryKind, + ) -> Self { + match self { + MediaLibraryScanner::Auto => Self::default_for_kind(kind), + scanner => scanner.clone(), + } + } + + /// Return the default scanner for a library kind. + pub fn default_for_kind(kind: &MediaLibraryKind) -> Self { + match kind { + MediaLibraryKind::Movies => MediaLibraryScanner::Movies, + MediaLibraryKind::Shows => MediaLibraryScanner::Shows, + MediaLibraryKind::Music => MediaLibraryScanner::Music, + MediaLibraryKind::Photos => MediaLibraryScanner::Photos, + MediaLibraryKind::Books => MediaLibraryScanner::Books, + MediaLibraryKind::Mixed | MediaLibraryKind::HomeVideos => { + MediaLibraryScanner::Directory + } + } + } + + /// Return the stable storage representation. + pub fn as_storage_value(&self) -> &'static str { + match self { + MediaLibraryScanner::Auto => "auto", + MediaLibraryScanner::Directory => "directory", + MediaLibraryScanner::Movies => "movies", + MediaLibraryScanner::Shows => "shows", + MediaLibraryScanner::Music => "music", + MediaLibraryScanner::Photos => "photos", + MediaLibraryScanner::Books => "books", + } + } + + /// Parse a scanner storage value. + pub fn from_storage_value(value: &str) -> Self { + match value.trim() { + "directory" => MediaLibraryScanner::Directory, + "movies" => MediaLibraryScanner::Movies, + "shows" => MediaLibraryScanner::Shows, + "music" => MediaLibraryScanner::Music, + "photos" => MediaLibraryScanner::Photos, + "books" => MediaLibraryScanner::Books, + _ => MediaLibraryScanner::Auto, + } + } +} + +fn default_recursive_scan() -> bool { + true +} + +fn default_ffmpeg_path() -> String { + "ffmpeg".into() +} + +fn default_ffprobe_path() -> String { + "ffprobe".into() +} + +fn default_metadata_language() -> String { + "en-US".into() +} + +fn default_metadata_languages() -> Vec { + vec![default_metadata_language()] +} + +fn default_provider_enabled() -> bool { + true +} + +fn default_provider_rate_limit_per_second() -> u32 { + 4 +} + +fn default_provider_retry_attempts() -> u32 { + 3 +} + +fn default_provider_retry_backoff_ms() -> u32 { + 1_000 +} + +fn is_false(value: &bool) -> bool { + !*value +} + +fn default_metadata_refresh_interval_days() -> Option { + Some(30) +} + +fn default_missing_item_auto_delete_days() -> Option { + None +} + +fn default_trash_cleanup_enabled() -> bool { + false +} + +fn default_scheduled_tasks_enabled() -> bool { + true +} + +fn default_scheduled_task_window_start() -> String { + "02:00".into() +} + +fn default_scheduled_task_window_stop() -> String { + "06:00".into() +} + +fn default_scheduled_task_weekdays() -> Vec { + vec![ + ScheduledTaskWeekday::Monday, + ScheduledTaskWeekday::Tuesday, + ScheduledTaskWeekday::Wednesday, + ScheduledTaskWeekday::Thursday, + ScheduledTaskWeekday::Friday, + ScheduledTaskWeekday::Saturday, + ScheduledTaskWeekday::Sunday, + ] +} + +fn default_database_maintenance_interval_days() -> u32 { + 7 +} + +fn default_trash_cleanup_interval_days() -> u32 { + 1 +} + +fn default_metadata_provider_settings(id: MetadataProviderId) -> MetadataProviderSettings { + match id { + MetadataProviderId::Tmdb => MetadataProviderSettings::default(), + MetadataProviderId::Tvdb => MetadataProviderSettings { + id: MetadataProviderId::Tvdb, + enabled: false, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + MetadataProviderId::MusicBrainz => MetadataProviderSettings { + id: MetadataProviderId::MusicBrainz, + enabled: false, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + MetadataProviderId::OpenLibrary => MetadataProviderSettings { + id: MetadataProviderId::OpenLibrary, + enabled: false, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + MetadataProviderId::LocalNfo => MetadataProviderSettings { + id: MetadataProviderId::LocalNfo, + enabled: true, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + MetadataProviderId::Themerr => MetadataProviderSettings { + id: MetadataProviderId::Themerr, + enabled: true, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + MetadataProviderId::TrailerDb => MetadataProviderSettings { + id: MetadataProviderId::TrailerDb, + enabled: true, + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + }, + } +} + +fn default_library_metadata_providers() -> Vec { + vec![MetadataProviderId::Tmdb] +} + +fn default_library_metadata_language_mode() -> MediaLibraryMetadataLanguageMode { + MediaLibraryMetadataLanguageMode::Auto +} + +fn normalized_unique_strings(values: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut normalized = Vec::new(); + + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); + } + } + + normalized +} + +/// How metadata languages are chosen for a library. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MediaLibraryMetadataLanguageMode { + /// Use every language preferred by users who can access the library. + #[default] + Auto, + /// Use the explicit language list configured on the library. + Manual, +} + +/// A configured media-library root. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +pub struct MediaLibrarySettings { + /// Human-friendly library name. + #[serde(default)] + pub name: String, + /// Filesystem path to the media-library root. + #[serde(default)] + pub path: String, + /// Filesystem paths for one logical library when multiple roots are configured. + #[serde(default)] + pub paths: Vec, + /// Whether the scanner should recurse into subdirectories. + #[serde(default = "default_recursive_scan")] + pub recursive: bool, + /// The intended media category for the library. + #[serde(default)] + pub kind: MediaLibraryKind, + /// Scanner used to inventory files for the library. + #[serde(default)] + pub scanner: MediaLibraryScanner, + /// Ordered metadata providers to use for this library. + #[serde(default = "default_library_metadata_providers")] + pub metadata_providers: Vec, + /// Whether metadata languages are inferred from library users or set manually. + #[serde(default = "default_library_metadata_language_mode")] + pub metadata_language_mode: MediaLibraryMetadataLanguageMode, + /// Ordered metadata languages to fetch and prefer for this library. + #[serde(default = "default_metadata_languages")] + pub metadata_languages: Vec, + /// User ids allowed to view this library. Empty means all users. + #[serde(default)] + pub allowed_user_ids: Vec, +} + +impl MediaLibrarySettings { + /// Return all configured filesystem roots for this logical library. + pub fn configured_paths(&self) -> Vec { + normalized_unique_strings( + std::iter::once(self.path.clone()).chain(self.paths.iter().cloned()), + ) + } + + /// Return the first configured filesystem root for this library, when present. + pub fn primary_path(&self) -> String { + self.configured_paths() + .into_iter() + .next() + .unwrap_or_default() + } + + /// Normalize path and provider settings for persistence. + pub fn normalize(&mut self) { + let normalized_paths = self.configured_paths(); + self.path = normalized_paths.first().cloned().unwrap_or_default(); + self.paths = normalized_paths; + self.metadata_providers = normalized_unique_strings( + self.metadata_providers + .iter() + .map(|provider| provider.as_storage_value().to_string()), + ) + .into_iter() + .filter_map(|value| MetadataProviderId::from_storage_value(&value)) + .collect(); + if self.metadata_providers.is_empty() { + self.metadata_providers = default_library_metadata_providers(); + } + if self.metadata_providers.is_empty() { + self.metadata_providers = default_library_metadata_providers(); + } + self.metadata_languages = normalized_unique_strings( + self.metadata_languages + .iter() + .map(|language| language.trim().to_string()), + ); + if self.metadata_languages.is_empty() { + self.metadata_languages = default_metadata_languages(); + } + self.allowed_user_ids.sort_unstable(); + self.allowed_user_ids.dedup(); + self.allowed_user_ids.retain(|user_id| *user_id > 0); + } +} + +/// Media scanning settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] +pub struct MediaSettings { + /// Configured media-library roots. + #[serde(default)] + pub libraries: Vec, + /// Automatically delete missing catalog items after this many days. `None` disables cleanup. + #[serde(default = "default_missing_item_auto_delete_days")] + pub missing_item_auto_delete_days: Option, +} + +/// Supported external metadata providers. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum MetadataProviderId { + /// TheMovieDB for movie and television metadata. + #[default] + Tmdb, + /// TheTVDB for movie and television metadata. + Tvdb, + /// MusicBrainz for music-oriented metadata. + #[serde(rename = "musicbrainz")] + MusicBrainz, + /// Open Library for book metadata. + OpenLibrary, + /// Local NFO files and sidecar metadata. + LocalNfo, + /// ThemerrDB theme-song metadata extension provider. + Themerr, + /// The Trailer Database trailer metadata extension provider. + #[serde(rename = "trailerdb")] + TrailerDb, +} + +impl MetadataProviderId { + /// Return the stable storage value for this provider identifier. + pub fn as_storage_value(&self) -> &'static str { + match self { + MetadataProviderId::Tmdb => "tmdb", + MetadataProviderId::Tvdb => "tvdb", + MetadataProviderId::MusicBrainz => "musicbrainz", + MetadataProviderId::OpenLibrary => "open_library", + MetadataProviderId::LocalNfo => "local_nfo", + MetadataProviderId::Themerr => "themerr", + MetadataProviderId::TrailerDb => "trailerdb", + } + } + + /// Parse a provider identifier from a stored string value. + pub fn from_storage_value(value: &str) -> Option { + match value.trim() { + "tmdb" => Some(MetadataProviderId::Tmdb), + "tvdb" => Some(MetadataProviderId::Tvdb), + "musicbrainz" => Some(MetadataProviderId::MusicBrainz), + "open_library" => Some(MetadataProviderId::OpenLibrary), + "local_nfo" => Some(MetadataProviderId::LocalNfo), + "themerr" => Some(MetadataProviderId::Themerr), + "trailerdb" => Some(MetadataProviderId::TrailerDb), + _ => None, + } + } +} + +fn metadata_provider_api_key_secret_ref(provider_id: &MetadataProviderId) -> String { + format!( + "metadata-provider:{}:api-key", + provider_id.as_storage_value() + ) +} + +fn normalized_secret_value(value: Option<&String>) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn metadata_provider_has_api_key_value(provider: &MetadataProviderSettings) -> bool { + normalized_secret_value(provider.api_key.as_ref()).is_some() + || normalized_secret_value(provider.api_key_secret_ref.as_ref()).is_some() +} + +pub(crate) fn metadata_provider_api_key_configured(provider: &MetadataProviderSettings) -> bool { + metadata_provider_has_api_key_value(provider) || provider.api_key_configured +} + +/// Configuration for one metadata provider. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderSettings { + /// Provider identifier. + #[serde(default)] + pub id: MetadataProviderId, + /// Whether this provider is enabled. + #[serde(default = "default_provider_enabled")] + pub enabled: bool, + /// Provider-specific API key or token, when a new value is submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Stable secret-store reference for this provider's API key or token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_secret_ref: Option, + /// Whether this provider has a saved API key or token. + #[serde(default, skip_deserializing, skip_serializing_if = "is_false")] + pub api_key_configured: bool, + /// Whether the saved API key or token should be removed. + #[serde(default, skip_serializing)] + pub clear_api_key: bool, + /// Preferred language for metadata results. + #[serde(default = "default_metadata_language")] + pub language: String, + /// Maximum request rate the provider should use when making API calls. + #[serde(default = "default_provider_rate_limit_per_second")] + pub rate_limit_per_second: u32, + /// Maximum number of retry attempts after transient provider failures. + #[serde(default = "default_provider_retry_attempts")] + pub retry_attempts: u32, + /// Base retry backoff in milliseconds. + #[serde(default = "default_provider_retry_backoff_ms")] + pub retry_backoff_ms: u32, +} + +/// Metadata acquisition settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataSettings { + /// Ordered list of enabled and optional providers. + #[serde(default)] + pub providers: Vec, + /// Automatic metadata refresh interval in days. `None` disables automatic refreshes. + #[serde(default = "default_metadata_refresh_interval_days")] + pub refresh_interval_days: Option, +} + +/// Days when scheduled tasks can run. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum ScheduledTaskWeekday { + /// Monday. + #[default] + Monday, + /// Tuesday. + Tuesday, + /// Wednesday. + Wednesday, + /// Thursday. + Thursday, + /// Friday. + Friday, + /// Saturday. + Saturday, + /// Sunday. + Sunday, +} + +/// Shared time window for scheduled tasks. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct ScheduledTaskWindowSettings { + /// Local time when scheduled tasks may start, formatted as HH:MM. + #[serde(default = "default_scheduled_task_window_start")] + pub start_time: String, + /// Local time when scheduled tasks must stop starting, formatted as HH:MM. + #[serde(default = "default_scheduled_task_window_stop")] + pub stop_time: String, + /// Local weekdays when scheduled tasks may run. Empty means every day. + #[serde(default = "default_scheduled_task_weekdays")] + pub weekdays: Vec, +} + +/// Scheduled metadata refresh task settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataRefreshTaskSettings { + /// Whether automatic stale metadata refreshes run from the scheduled task runner. + #[serde(default = "default_scheduled_tasks_enabled")] + pub enabled: bool, +} + +/// Scheduled trash cleanup task settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct TrashCleanupTaskSettings { + /// Whether missing media item cleanup runs automatically. + #[serde(default = "default_trash_cleanup_enabled")] + pub enabled: bool, + /// Number of days an item must be missing before automatic cleanup deletes it. + #[serde(default = "default_missing_item_auto_delete_days")] + pub missing_item_auto_delete_days: Option, + /// Minimum number of days between trash cleanup runs. + #[serde(default = "default_trash_cleanup_interval_days")] + pub interval_days: u32, +} + +/// Scheduled database maintenance task settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct DatabaseMaintenanceTaskSettings { + /// Whether database checkpoint, cleanup, and vacuum maintenance runs automatically. + #[serde(default = "default_scheduled_tasks_enabled")] + pub enabled: bool, + /// Minimum number of days between database maintenance runs. + #[serde(default = "default_database_maintenance_interval_days")] + pub interval_days: u32, +} + +/// Scheduled task settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct ScheduledTasksSettings { + /// Whether the scheduled task runner is enabled. + #[serde(default = "default_scheduled_tasks_enabled")] + pub enabled: bool, + /// Shared local run window for scheduled tasks. + #[serde(default)] + pub window: ScheduledTaskWindowSettings, + /// Stale metadata refresh task. + #[serde(default)] + pub metadata_refresh: MetadataRefreshTaskSettings, + /// Missing item trash cleanup task. + #[serde(default)] + pub trash_cleanup: TrashCleanupTaskSettings, + /// Database cleanup and vacuum task. + #[serde(default)] + pub database_maintenance: DatabaseMaintenanceTaskSettings, +} + +/// FFmpeg-related tooling settings. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct FfmpegSettings { + /// Path or command name for the FFmpeg executable. + #[serde(default = "default_ffmpeg_path")] + pub ffmpeg_path: String, + /// Path or command name for the ffprobe executable. + #[serde(default = "default_ffprobe_path")] + pub ffprobe_path: String, +} + /// Server settings. -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] pub struct ServerSettings { /// Whether to use HTTPS. #[serde(default)] @@ -41,14 +672,26 @@ pub struct ServerSettings { } /// Application settings. -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Default)] pub struct Settings { /// General settings. #[serde(default)] pub general: GeneralSettings, + /// Media settings. + #[serde(default)] + pub media: MediaSettings, + /// Metadata-provider settings. + #[serde(default)] + pub metadata: MetadataSettings, /// Server settings. #[serde(default)] pub server: ServerSettings, + /// FFmpeg tooling settings. + #[serde(default)] + pub ffmpeg: FfmpegSettings, + /// Scheduled task settings. + #[serde(default)] + pub scheduled_tasks: ScheduledTasksSettings, } impl Default for GeneralSettings { @@ -78,11 +721,101 @@ impl Default for ServerSettings { } } +impl Default for FfmpegSettings { + fn default() -> Self { + Self { + ffmpeg_path: default_ffmpeg_path(), + ffprobe_path: default_ffprobe_path(), + } + } +} + +impl Default for MetadataProviderSettings { + fn default() -> Self { + Self { + id: MetadataProviderId::Tmdb, + enabled: default_provider_enabled(), + api_key: None, + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: default_metadata_language(), + rate_limit_per_second: default_provider_rate_limit_per_second(), + retry_attempts: default_provider_retry_attempts(), + retry_backoff_ms: default_provider_retry_backoff_ms(), + } + } +} + +impl Default for MetadataSettings { + fn default() -> Self { + Self { + providers: vec![ + default_metadata_provider_settings(MetadataProviderId::Tmdb), + default_metadata_provider_settings(MetadataProviderId::Tvdb), + default_metadata_provider_settings(MetadataProviderId::MusicBrainz), + default_metadata_provider_settings(MetadataProviderId::OpenLibrary), + default_metadata_provider_settings(MetadataProviderId::LocalNfo), + default_metadata_provider_settings(MetadataProviderId::Themerr), + default_metadata_provider_settings(MetadataProviderId::TrailerDb), + ], + refresh_interval_days: default_metadata_refresh_interval_days(), + } + } +} + +impl Default for ScheduledTaskWindowSettings { + fn default() -> Self { + Self { + start_time: default_scheduled_task_window_start(), + stop_time: default_scheduled_task_window_stop(), + weekdays: default_scheduled_task_weekdays(), + } + } +} + +impl Default for MetadataRefreshTaskSettings { + fn default() -> Self { + Self { + enabled: default_scheduled_tasks_enabled(), + } + } +} + +impl Default for TrashCleanupTaskSettings { + fn default() -> Self { + Self { + enabled: default_trash_cleanup_enabled(), + missing_item_auto_delete_days: default_missing_item_auto_delete_days(), + interval_days: default_trash_cleanup_interval_days(), + } + } +} + +impl Default for DatabaseMaintenanceTaskSettings { + fn default() -> Self { + Self { + enabled: default_scheduled_tasks_enabled(), + interval_days: default_database_maintenance_interval_days(), + } + } +} + +impl Default for ScheduledTasksSettings { + fn default() -> Self { + Self { + enabled: default_scheduled_tasks_enabled(), + window: ScheduledTaskWindowSettings::default(), + metadata_refresh: MetadataRefreshTaskSettings::default(), + trash_cleanup: TrashCleanupTaskSettings::default(), + database_maintenance: DatabaseMaintenanceTaskSettings::default(), + } + } +} + impl Settings { /// Create a new instance of `Settings`. pub fn new() -> Result { - // Start with defaults provided via set_default and then merge in any provided config file - // or environment variables. let config = Config::builder() .set_default("general.data_dir", GeneralSettings::default().data_dir)? .set_default("server.use_https", ServerSettings::default().use_https)? @@ -94,24 +827,12 @@ impl Settings { "server.use_custom_certs", ServerSettings::default().use_custom_certs, )? - // Add other configuration sources; values here will override the defaults. - .add_source( - File::with_name( - config_local_dir() - .unwrap() - .join(GLOBAL_APP_NAME) - .join("settings") - .to_str() - .unwrap(), - ) - .required(false), - ) + .add_source(File::with_name(settings_base_path().to_str().unwrap()).required(false)) .add_source(Environment::with_prefix( GLOBAL_APP_NAME.to_uppercase().as_str(), )) .build()?; - // Deserialize the configuration into our Settings struct. config.try_deserialize() } @@ -121,5 +842,516 @@ impl Settings { } } -/// Global settings for the application. -pub static GLOBAL_SETTINGS: Lazy = Lazy::new(Settings::load); +/// Normalize settings values before persistence or runtime replacement. +pub fn normalize_settings(settings: &mut Settings) { + if let Some(days) = settings.metadata.refresh_interval_days { + settings.metadata.refresh_interval_days = match days { + 30 | 60 | 90 => Some(days), + _ => default_metadata_refresh_interval_days(), + }; + } + + for library in &mut settings.media.libraries { + library.normalize(); + } + if let Some(days) = settings.media.missing_item_auto_delete_days.take() { + if days > 0 + && settings + .scheduled_tasks + .trash_cleanup + .missing_item_auto_delete_days + .is_none() + { + settings.scheduled_tasks.trash_cleanup.enabled = true; + settings + .scheduled_tasks + .trash_cleanup + .missing_item_auto_delete_days = Some(days.min(3650)); + } + } + normalize_scheduled_tasks_settings(&mut settings.scheduled_tasks); + + let mut seen_provider_ids = std::collections::HashSet::new(); + settings + .metadata + .providers + .retain(|provider| seen_provider_ids.insert(provider.id.clone())); + for provider in &mut settings.metadata.providers { + provider.language = { + let trimmed = provider.language.trim(); + if trimmed.is_empty() { default_metadata_language() } else { trimmed.to_string() } + }; + provider.rate_limit_per_second = provider.rate_limit_per_second.max(1); + provider.retry_backoff_ms = provider.retry_backoff_ms.max(1); + provider.api_key = normalized_secret_value(provider.api_key.as_ref()); + provider.api_key_secret_ref = normalized_secret_value(provider.api_key_secret_ref.as_ref()); + provider.api_key_configured = metadata_provider_has_api_key_value(provider); + if provider.clear_api_key { + provider.api_key_configured = false; + } + if provider.id == MetadataProviderId::Tvdb && provider.api_key_configured { + // Older settings snapshots can retain TVDB as disabled even after an API key + // is saved, which leaves TVDB-only libraries unusable. + provider.enabled = true; + } + } + + for provider_id in [ + MetadataProviderId::Tmdb, + MetadataProviderId::Tvdb, + MetadataProviderId::MusicBrainz, + MetadataProviderId::OpenLibrary, + MetadataProviderId::LocalNfo, + MetadataProviderId::Themerr, + MetadataProviderId::TrailerDb, + ] { + if !settings + .metadata + .providers + .iter() + .any(|provider| provider.id == provider_id) + { + settings + .metadata + .providers + .push(default_metadata_provider_settings(provider_id)); + } + } +} + +pub(crate) fn merge_metadata_provider_secret_state( + settings: &mut Settings, + existing: &Settings, +) { + let existing_providers = existing + .metadata + .providers + .iter() + .map(|provider| (provider.id.clone(), provider.clone())) + .collect::>(); + + for provider in &mut settings.metadata.providers { + let existing_provider = existing_providers.get(&provider.id); + let submitted_api_key = normalized_secret_value(provider.api_key.as_ref()); + provider.api_key = submitted_api_key; + + if provider.clear_api_key { + if provider.api_key_secret_ref.is_none() { + provider.api_key_secret_ref = + existing_provider.and_then(|provider| provider.api_key_secret_ref.clone()); + } + continue; + } + + if provider.api_key.is_none() { + if let Some(existing_provider) = existing_provider { + provider.api_key_secret_ref = existing_provider.api_key_secret_ref.clone(); + if provider.api_key_secret_ref.is_none() { + provider.api_key = normalized_secret_value(existing_provider.api_key.as_ref()); + } + provider.api_key_configured = + metadata_provider_api_key_configured(existing_provider); + } + } + } +} + +fn persist_metadata_provider_secret(provider: &mut MetadataProviderSettings) -> Result<(), String> { + if provider.clear_api_key { + if let Some(secret_ref) = provider.api_key_secret_ref.take() { + crate::secrets::delete_secret(&secret_ref)?; + } + provider.api_key = None; + provider.api_key_configured = false; + provider.clear_api_key = false; + return Ok(()); + } + + if let Some(api_key) = provider.api_key.take() { + let secret_ref = provider + .api_key_secret_ref + .clone() + .unwrap_or_else(|| metadata_provider_api_key_secret_ref(&provider.id)); + crate::secrets::store_secret(&secret_ref, &api_key)?; + provider.api_key_secret_ref = Some(secret_ref); + provider.api_key_configured = true; + } else { + provider.api_key_configured = provider.api_key_secret_ref.is_some(); + } + provider.clear_api_key = false; + + Ok(()) +} + +pub(crate) fn settings_with_persisted_secrets(settings: &Settings) -> Result { + let mut normalized = settings.clone(); + normalize_settings(&mut normalized); + + for provider in &mut normalized.metadata.providers { + persist_metadata_provider_secret(provider)?; + } + normalize_settings(&mut normalized); + Ok(normalized) +} + +pub(crate) fn settings_for_api_response(settings: &Settings) -> Settings { + let mut redacted = settings.clone(); + normalize_settings(&mut redacted); + for provider in &mut redacted.metadata.providers { + provider.api_key = None; + provider.api_key_configured = metadata_provider_api_key_configured(provider); + provider.api_key_secret_ref = None; + provider.clear_api_key = false; + } + redacted +} + +pub(crate) fn resolve_metadata_provider_api_key( + provider: &mut MetadataProviderSettings +) -> Result<(), String> { + if normalized_secret_value(provider.api_key.as_ref()).is_some() { + provider.api_key = normalized_secret_value(provider.api_key.as_ref()); + return Ok(()); + } + + let Some(secret_ref) = normalized_secret_value(provider.api_key_secret_ref.as_ref()) else { + provider.api_key = None; + provider.api_key_configured = false; + return Ok(()); + }; + + provider.api_key = crate::secrets::load_secret(&secret_ref)?; + provider.api_key_configured = provider.api_key.is_some(); + Ok(()) +} + +fn normalize_scheduled_time( + value: &mut String, + default_value: String, +) { + let trimmed = value.trim(); + let parts = trimmed.split(':').collect::>(); + let parsed = if parts.len() == 2 { + parts[0] + .parse::() + .ok() + .zip(parts[1].parse::().ok()) + .filter(|(hour, minute)| *hour < 24 && *minute < 60) + } else { + None + }; + + *value = if let Some((hour, minute)) = parsed { + format!("{hour:02}:{minute:02}") + } else { + default_value + }; +} + +fn normalize_scheduled_tasks_settings(settings: &mut ScheduledTasksSettings) { + normalize_scheduled_time( + &mut settings.window.start_time, + default_scheduled_task_window_start(), + ); + normalize_scheduled_time( + &mut settings.window.stop_time, + default_scheduled_task_window_stop(), + ); + settings.window.weekdays.sort_by_key(|day| match day { + ScheduledTaskWeekday::Monday => 1, + ScheduledTaskWeekday::Tuesday => 2, + ScheduledTaskWeekday::Wednesday => 3, + ScheduledTaskWeekday::Thursday => 4, + ScheduledTaskWeekday::Friday => 5, + ScheduledTaskWeekday::Saturday => 6, + ScheduledTaskWeekday::Sunday => 7, + }); + settings.window.weekdays.dedup(); + if settings.window.weekdays.is_empty() { + settings.window.weekdays = default_scheduled_task_weekdays(); + } + if let Some(days) = settings.trash_cleanup.missing_item_auto_delete_days { + settings.trash_cleanup.missing_item_auto_delete_days = (days > 0).then_some(days.min(3650)); + } + if settings.trash_cleanup.enabled + && settings + .trash_cleanup + .missing_item_auto_delete_days + .is_none() + { + settings.trash_cleanup.missing_item_auto_delete_days = Some(30); + } + settings.trash_cleanup.interval_days = settings.trash_cleanup.interval_days.clamp(1, 365); + settings.database_maintenance.interval_days = + settings.database_maintenance.interval_days.clamp(1, 365); +} + +/// Return a settings snapshot suitable for YAML persistence. +pub fn settings_for_persistence(settings: &Settings) -> Settings { + let mut normalized = settings.clone(); + normalize_settings(&mut normalized); + normalized.media.libraries.clear(); + for provider in &mut normalized.metadata.providers { + provider.api_key = None; + provider.api_key_configured = metadata_provider_api_key_configured(provider); + provider.clear_api_key = false; + } + normalized +} + +/// Serialize settings to YAML for disk persistence, omitting DB-owned library settings. +pub fn settings_yaml_for_persistence(settings: &Settings) -> Result { + let normalized = settings_for_persistence(settings); + let mut value = serde_yaml::to_value(&normalized).map_err(|error| error.to_string())?; + + if let serde_yaml::Value::Mapping(root) = &mut value { + let general_key = serde_yaml::Value::String("general".into()); + if let Some(general) = root.get(&general_key).cloned() { + root.clear(); + root.insert(general_key, general); + } + } + + serde_yaml::to_string(&value).map_err(|error| error.to_string()) +} + +fn runtime_setting_value(value: &T) -> Result { + serde_json::to_string(value).map_err(|error| error.to_string()) +} + +fn parse_runtime_setting Deserialize<'de>>( + value: &str, + key: &str, +) -> Result { + serde_json::from_str(value) + .map_err(|error| format!("Failed to parse persisted {key} settings: {error}")) +} + +fn media_settings_for_database(settings: &Settings) -> MediaSettings { + let mut media = settings.media.clone(); + media.libraries.clear(); + media +} + +fn upsert_runtime_setting( + conn: &mut diesel::SqliteConnection, + key: &str, + value: String, +) -> Result<(), String> { + use crate::db::schema::app_settings::dsl as app_settings_dsl; + + diesel::insert_into(app_settings_dsl::app_settings) + .values(AppSetting { + key: key.to_string(), + value, + updated_at: Some(chrono::Utc::now().timestamp()), + }) + .on_conflict(app_settings_dsl::key) + .do_update() + .set(( + app_settings_dsl::value.eq(diesel::upsert::excluded(app_settings_dsl::value)), + app_settings_dsl::updated_at.eq(diesel::upsert::excluded(app_settings_dsl::updated_at)), + )) + .execute(conn) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn insert_runtime_setting_if_missing( + conn: &mut diesel::SqliteConnection, + key: &str, + value: String, +) -> Result<(), String> { + use crate::db::schema::app_settings::dsl as app_settings_dsl; + + diesel::insert_into(app_settings_dsl::app_settings) + .values(AppSetting { + key: key.to_string(), + value, + updated_at: Some(chrono::Utc::now().timestamp()), + }) + .on_conflict_do_nothing() + .execute(conn) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// Persist DB-owned runtime settings to SQLite. +pub fn save_database_settings( + conn: &mut diesel::SqliteConnection, + settings: &Settings, +) -> Result<(), String> { + let normalized = settings_with_persisted_secrets(settings)?; + + upsert_runtime_setting( + conn, + METADATA_SETTINGS_KEY, + runtime_setting_value(&normalized.metadata)?, + )?; + upsert_runtime_setting( + conn, + MEDIA_SETTINGS_KEY, + runtime_setting_value(&media_settings_for_database(&normalized))?, + )?; + upsert_runtime_setting( + conn, + SERVER_SETTINGS_KEY, + runtime_setting_value(&normalized.server)?, + )?; + upsert_runtime_setting( + conn, + FFMPEG_SETTINGS_KEY, + runtime_setting_value(&normalized.ffmpeg)?, + )?; + upsert_runtime_setting( + conn, + SCHEDULED_TASKS_SETTINGS_KEY, + runtime_setting_value(&normalized.scheduled_tasks)?, + )?; + Ok(()) +} + +/// Seed DB-owned runtime settings from bootstrap settings when no DB value exists yet. +pub fn seed_database_settings( + conn: &mut diesel::SqliteConnection, + settings: &Settings, +) -> Result<(), String> { + let normalized = settings_with_persisted_secrets(settings)?; + + insert_runtime_setting_if_missing( + conn, + METADATA_SETTINGS_KEY, + runtime_setting_value(&normalized.metadata)?, + )?; + insert_runtime_setting_if_missing( + conn, + MEDIA_SETTINGS_KEY, + runtime_setting_value(&media_settings_for_database(&normalized))?, + )?; + insert_runtime_setting_if_missing( + conn, + SERVER_SETTINGS_KEY, + runtime_setting_value(&normalized.server)?, + )?; + insert_runtime_setting_if_missing( + conn, + FFMPEG_SETTINGS_KEY, + runtime_setting_value(&normalized.ffmpeg)?, + )?; + insert_runtime_setting_if_missing( + conn, + SCHEDULED_TASKS_SETTINGS_KEY, + runtime_setting_value(&normalized.scheduled_tasks)?, + )?; + Ok(()) +} + +/// Load DB-owned runtime settings and merge them over the bootstrap settings. +pub fn load_database_settings( + conn: &mut diesel::SqliteConnection, + bootstrap: &Settings, +) -> Result { + let rows = app_settings::table + .filter(app_settings::key.eq_any([ + METADATA_SETTINGS_KEY, + MEDIA_SETTINGS_KEY, + SERVER_SETTINGS_KEY, + FFMPEG_SETTINGS_KEY, + SCHEDULED_TASKS_SETTINGS_KEY, + ])) + .select(AppSetting::as_select()) + .load::(conn) + .map_err(|error| error.to_string())?; + + let mut settings = bootstrap.clone(); + for row in rows { + match row.key.as_str() { + METADATA_SETTINGS_KEY => { + settings.metadata = parse_runtime_setting(&row.value, METADATA_SETTINGS_KEY)?; + } + MEDIA_SETTINGS_KEY => { + let libraries = settings.media.libraries.clone(); + settings.media = parse_runtime_setting(&row.value, MEDIA_SETTINGS_KEY)?; + settings.media.libraries = libraries; + } + SERVER_SETTINGS_KEY => { + settings.server = parse_runtime_setting(&row.value, SERVER_SETTINGS_KEY)?; + } + FFMPEG_SETTINGS_KEY => { + settings.ffmpeg = parse_runtime_setting(&row.value, FFMPEG_SETTINGS_KEY)?; + } + SCHEDULED_TASKS_SETTINGS_KEY => { + settings.scheduled_tasks = + parse_runtime_setting(&row.value, SCHEDULED_TASKS_SETTINGS_KEY)?; + } + _ => {} + } + } + let has_plaintext_provider_secrets = settings + .metadata + .providers + .iter() + .any(|provider| normalized_secret_value(provider.api_key.as_ref()).is_some()); + normalize_settings(&mut settings); + if has_plaintext_provider_secrets { + settings = settings_with_persisted_secrets(&settings)?; + save_database_settings(conn, &settings)?; + } + Ok(settings) +} + +fn settings_base_path() -> PathBuf { + settings_directory_path().join("settings") +} + +/// Return the settings directory path. +pub fn settings_directory_path() -> PathBuf { + if let Ok(path) = std::env::var("KOKO_SETTINGS_DIR") { + let path = path.trim(); + if !path.is_empty() { + return PathBuf::from(path); + } + } + + config_local_dir().unwrap().join(GLOBAL_APP_NAME) +} + +/// Return the YAML settings file path. +pub fn settings_file_path() -> PathBuf { + if let Ok(path) = std::env::var("KOKO_SETTINGS_PATH") { + let path = path.trim(); + if !path.is_empty() { + return PathBuf::from(path); + } + } + + settings_directory_path().join("settings.yml") +} + +/// Save settings to disk. +pub fn save_settings(settings: &Settings) -> Result<(), String> { + let settings_path = settings_file_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + + let yaml = settings_yaml_for_persistence(settings)?; + fs::write(settings_path, yaml).map_err(|error| error.to_string()) +} + +/// Global mutable settings state for the application. +pub static CURRENT_SETTINGS: Lazy> = Lazy::new(|| RwLock::new(Settings::load())); + +/// Return a clone of the current in-memory settings. +pub fn current_settings() -> Settings { + let mut settings = CURRENT_SETTINGS.read().unwrap().clone(); + normalize_settings(&mut settings); + settings +} + +/// Replace the in-memory settings state. +pub fn replace_current_settings(settings: Settings) { + let mut normalized = settings; + normalize_settings(&mut normalized); + *CURRENT_SETTINGS.write().unwrap() = normalized; +} diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs index 4719bf22..b98573ba 100644 --- a/crates/server/src/db/mod.rs +++ b/crates/server/src/db/mod.rs @@ -3,17 +3,264 @@ pub(crate) mod models; pub(crate) mod schema; +// standard imports +use std::collections::{ + HashMap, + HashSet, +}; +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::Path; + // lib imports -use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; +use diesel::Connection; +use diesel::connection::SimpleConnection; +use diesel::migration::{ + Migration, + MigrationSource, + MigrationVersion, + Result as MigrationResult, +}; +use diesel_migrations::{ + EmbeddedMigrations, + MigrationHarness, + embed_migrations, +}; use rocket::{ Build, Rocket, - fairing::{Fairing, Info, Kind}, + fairing::{ + Fairing, + Info, + Kind, + }, +}; +use rocket_sync_db_pools::{ + database, + diesel, }; -use rocket_sync_db_pools::{database, diesel}; /// Embedded migrations for the SQLite database. -pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations"); +const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations"); + +/// Ordered SQLite migration revisions. +/// +/// Diesel stores migration versions as text and normally sorts pending migrations +/// by that text. Keep the opaque revision IDs here in the exact order they must +/// be applied. +const SQLITE_MIGRATION_ORDER: &[&str] = &["a54d52c8da5e"]; + +#[derive(Debug)] +struct MigrationOrderError(String); + +impl fmt::Display for MigrationOrderError { + fn fmt( + &self, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for MigrationOrderError {} + +/// Apply SQLite pragmas that improve concurrency and reduce lock contention. +pub fn configure_sqlite_connection( + conn: &mut diesel::SqliteConnection +) -> diesel::result::QueryResult<()> { + conn.batch_execute( + "PRAGMA foreign_keys = ON;PRAGMA journal_mode = WAL;PRAGMA synchronous = NORMAL;PRAGMA \ + busy_timeout = 5000;", + ) +} + +/// Run pending SQLite migrations in the order declared by `SQLITE_MIGRATION_ORDER`. +pub fn run_pending_sqlite_migrations( + conn: &mut diesel::SqliteConnection +) -> MigrationResult>> { + let applied_versions = applied_sqlite_migration_versions(conn)?; + let mut migrations_by_version = sqlite_migrations_by_version()?; + validate_sqlite_migration_order(&migrations_by_version)?; + + let mut applied = Vec::new(); + for version in SQLITE_MIGRATION_ORDER { + if applied_versions.contains(*version) { + continue; + } + + let Some(migration) = migrations_by_version.remove(*version) else { + return migration_order_error(format!( + "SQLite migration revision {version} is listed but not embedded" + )); + }; + applied.push(conn.run_migration(&*migration)?); + } + + Ok(applied) +} + +/// Revert applied SQLite migrations in reverse `SQLITE_MIGRATION_ORDER`. +pub fn revert_all_sqlite_migrations( + conn: &mut diesel::SqliteConnection +) -> MigrationResult>> { + let applied_versions = applied_sqlite_migration_versions(conn)?; + let mut migrations_by_version = sqlite_migrations_by_version()?; + validate_sqlite_migration_order(&migrations_by_version)?; + + let mut reverted = Vec::new(); + for version in SQLITE_MIGRATION_ORDER.iter().rev() { + if !applied_versions.contains(*version) { + continue; + } + + let Some(migration) = migrations_by_version.remove(*version) else { + return migration_order_error(format!( + "SQLite migration revision {version} is listed but not embedded" + )); + }; + reverted.push(conn.revert_migration(&*migration)?); + } + + Ok(reverted) +} + +fn sqlite_migrations_by_version() +-> MigrationResult>>> { + let migrations = + >::migrations(&MIGRATIONS)?; + + Ok(migrations + .into_iter() + .map(|migration| (migration.name().version().to_string(), migration)) + .collect()) +} + +fn applied_sqlite_migration_versions( + conn: &mut diesel::SqliteConnection +) -> MigrationResult> { + let mut versions = HashSet::new(); + for version in conn.applied_migrations()? { + let version = version.to_string(); + if SQLITE_MIGRATION_ORDER.contains(&version.as_str()) { + versions.insert(version); + } + } + + Ok(versions) +} + +fn validate_sqlite_migration_order( + migrations_by_version: &HashMap>> +) -> MigrationResult<()> { + let mut listed_versions = HashSet::new(); + let mut duplicate_versions = Vec::new(); + for version in SQLITE_MIGRATION_ORDER { + if !listed_versions.insert(*version) { + duplicate_versions.push((*version).to_owned()); + } + } + duplicate_versions.sort(); + if !duplicate_versions.is_empty() { + return migration_order_error(format!( + "Duplicate SQLite migration revisions in SQLITE_MIGRATION_ORDER: {}", + duplicate_versions.join(", ") + )); + } + + let mut missing_versions = SQLITE_MIGRATION_ORDER + .iter() + .copied() + .filter(|version| !migrations_by_version.contains_key(*version)) + .collect::>(); + missing_versions.sort_unstable(); + if !missing_versions.is_empty() { + return migration_order_error(format!( + "SQLite migration revisions are listed but not embedded: {}", + missing_versions.join(", ") + )); + } + + let mut unlisted_versions = migrations_by_version + .keys() + .filter(|version| !listed_versions.contains(version.as_str())) + .cloned() + .collect::>(); + unlisted_versions.sort(); + if !unlisted_versions.is_empty() { + return migration_order_error(format!( + "SQLite migration revisions are embedded but missing from SQLITE_MIGRATION_ORDER: {}", + unlisted_versions.join(", ") + )); + } + + Ok(()) +} + +fn migration_order_error(message: String) -> MigrationResult { + Err(Box::new(MigrationOrderError(message))) +} + +/// Prepare the SQLite database path before Rocket initializes the pool. +pub fn prepare_sqlite_database_path(db_path: &str) { + if let Some(parent) = Path::new(db_path).parent() { + if let Err(error) = fs::create_dir_all(parent) { + log::warn!( + "Failed to create database directory {:?}: {}", + parent, + error + ); + } + } + + clear_stale_sqlite_lock_files(db_path); +} + +/// Prepare a SQLite database and run embedded migrations outside the Rocket pool. +pub fn initialize_sqlite_database(db_path: &str) -> Result<(), String> { + prepare_sqlite_database_path(db_path); + let mut conn = diesel::SqliteConnection::establish(db_path) + .map_err(|error| format!("Failed to open SQLite database {db_path}: {error}"))?; + configure_sqlite_connection(&mut conn) + .map_err(|error| format!("Failed to configure SQLite database {db_path}: {error}"))?; + run_pending_sqlite_migrations(&mut conn) + .map_err(|error| format!("Failed to run SQLite migrations {db_path}: {error}"))?; + Ok(()) +} + +fn clear_stale_sqlite_lock_files(db_path: &str) { + let Ok(mut conn) = diesel::SqliteConnection::establish(db_path) else { + return; + }; + if configure_sqlite_connection(&mut conn).is_err() { + return; + } + if conn.batch_execute("BEGIN IMMEDIATE; ROLLBACK;").is_err() { + log::warn!("SQLite database appears to be locked by another active process"); + return; + } + + let _ = conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);"); + let journal_path = format!("{db_path}-journal"); + if Path::new(&journal_path).exists() { + if let Err(error) = fs::remove_file(&journal_path) { + log::debug!( + "SQLite rollback journal {} was not removed: {}", + journal_path, + error + ); + } + } +} + +fn release_sqlite_database_lock(conn: &mut diesel::SqliteConnection) { + if let Err(error) = conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);PRAGMA optimize;") { + log::warn!( + "Failed to checkpoint SQLite database during shutdown: {}", + error + ); + } +} /// Database connection fairing. #[database("sqlite_db")] @@ -22,6 +269,9 @@ pub struct DbConn(diesel::SqliteConnection); /// Fairing to run migrations when the application starts. pub struct Migrate; +/// Fairing to checkpoint SQLite and release database locks during shutdown. +pub struct ReleaseDatabase; + #[rocket::async_trait] impl Fairing for Migrate { fn info(&self) -> Info { @@ -38,8 +288,8 @@ impl Fairing for Migrate { if let Some(conn) = DbConn::get_one(&rocket).await { let _ = conn .run(|c| { - c.run_pending_migrations(MIGRATIONS) - .expect("Failed to run migrations"); + configure_sqlite_connection(c).expect("Failed to configure SQLite connection"); + run_pending_sqlite_migrations(c).expect("Failed to run migrations"); }) .await; } @@ -47,6 +297,25 @@ impl Fairing for Migrate { } } +#[rocket::async_trait] +impl Fairing for ReleaseDatabase { + fn info(&self) -> Info { + Info { + name: "Release Database", + kind: Kind::Shutdown, + } + } + + async fn on_shutdown( + &self, + rocket: &Rocket, + ) { + if let Some(conn) = DbConn::get_one(rocket).await { + conn.run(release_sqlite_database_lock).await; + } + } +} + impl rocket_okapi::request::OpenApiFromRequest<'_> for DbConn { fn from_request_input( _gen: &mut rocket_okapi::gen::OpenApiGenerator, diff --git a/crates/server/src/db/models.rs b/crates/server/src/db/models.rs index dcea4d07..9c253617 100644 --- a/crates/server/src/db/models.rs +++ b/crates/server/src/db/models.rs @@ -4,7 +4,34 @@ use diesel::prelude::*; // local imports -use crate::db::schema::users; +use crate::db::schema::{ + app_settings, + external_media, + item_metadata_external_ids, + item_metadata_links, + item_metadata_people, + media_file_libraries, + media_files, + media_items, + media_libraries, + metadata_collection_items, + metadata_collections, + metadata_extras, + metadata_people, + metadata_person_credits, + metadata_person_external_ids, + playback_progress, + scan_state, + users, +}; + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = app_settings)] +pub struct AppSetting { + pub key: String, + pub value: String, + pub updated_at: Option, +} #[derive(Queryable, Selectable, Insertable, Debug)] #[diesel(table_name = users)] @@ -15,4 +42,544 @@ pub struct User { pub password: String, pub pin: Option, pub admin: bool, + pub birthday: Option, + pub profile_image_path: Option, + pub preferred_metadata_languages_json: String, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = media_libraries)] +pub struct MediaLibrary { + pub id: i32, + pub name: String, + pub path: String, + pub paths_json: String, + pub kind: String, + pub scanner: String, + pub recursive: bool, + pub metadata_providers_json: String, + pub metadata_language_mode: String, + pub metadata_languages_json: String, + pub allowed_user_ids_json: String, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_libraries)] +pub struct NewMediaLibrary { + pub name: String, + pub path: String, + pub paths_json: String, + pub kind: String, + pub scanner: String, + pub recursive: bool, + pub metadata_providers_json: String, + pub metadata_language_mode: String, + pub metadata_languages_json: String, + pub allowed_user_ids_json: String, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(table_name = scan_state)] +pub struct ScanState { + pub id: i32, + pub library_id: i32, + pub last_status: String, + pub last_error: Option, + pub scan_revision: i64, + pub last_scanned_at: Option, + pub total_files: i64, + pub video_files: i64, + pub audio_files: i64, + pub image_files: i64, + pub book_files: i64, + pub other_files: i64, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = scan_state)] +#[diesel(treat_none_as_null = true)] +pub struct NewScanState { + pub library_id: i32, + pub last_status: String, + pub last_error: Option, + pub scan_revision: i64, + pub last_scanned_at: Option, + pub total_files: i64, + pub video_files: i64, + pub audio_files: i64, + pub image_files: i64, + pub book_files: i64, + pub other_files: i64, +} + +#[derive(Queryable, Selectable, Identifiable, Debug)] +#[diesel(table_name = media_files)] +pub struct MediaFile { + pub id: i32, + pub path: String, + pub file_size: i64, + pub modified_at: Option, + pub media_kind: String, + pub file_hash: String, + pub container: Option, + pub duration_ms: Option, + pub bit_rate: Option, + pub width: Option, + pub height: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub metadata_json: Option, + pub metadata_updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaFile, foreign_key = media_file_id))] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(table_name = media_file_libraries)] +pub struct MediaFileLibrary { + pub id: i32, + pub media_file_id: i32, + pub library_id: i32, + pub source_root_path: String, + pub relative_path: String, + pub display_title: Option, + pub metadata_match_attempted_at: Option, + pub media_item_id: Option, + pub missing_since: Option, + pub deleted_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(MediaLibrary, foreign_key = library_id))] +#[diesel(belongs_to(MediaItem, foreign_key = parent_id))] +#[diesel(table_name = media_items)] +pub struct MediaItem { + pub id: i32, + pub library_id: i32, + pub parent_id: Option, + pub identity_key: String, + pub item_type: String, + pub display_title: String, + pub relative_path: Option, + pub media_kind: Option, + pub season_number: Option, + pub episode_number: Option, + pub child_count: i32, + pub playable: bool, + pub file_size: Option, + pub duration_ms: Option, + pub modified_at: Option, + pub created_at: Option, + pub updated_at: Option, + pub missing_since: Option, + pub deleted_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_items)] +#[diesel(treat_none_as_null = true)] +pub struct NewMediaItem { + pub library_id: i32, + pub parent_id: Option, + pub identity_key: String, + pub item_type: String, + pub display_title: String, + pub relative_path: Option, + pub media_kind: Option, + pub season_number: Option, + pub episode_number: Option, + pub child_count: i32, + pub playable: bool, + pub file_size: Option, + pub duration_ms: Option, + pub modified_at: Option, + pub created_at: Option, + pub updated_at: Option, + pub missing_since: Option, + pub deleted_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = external_media)] +pub struct ExternalMedia { + pub id: i32, + pub source: String, + pub external_id: Option, + pub url: String, + pub media_kind: String, + pub title: Option, + pub duration_seconds: Option, + pub thumbnail_url: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = external_media)] +#[diesel(treat_none_as_null = true)] +pub struct NewExternalMedia { + pub source: String, + pub external_id: Option, + pub url: String, + pub media_kind: String, + pub title: Option, + pub duration_seconds: Option, + pub thumbnail_url: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(table_name = item_metadata_links)] +pub struct ItemMetadataLink { + pub id: i32, + pub media_item_id: i32, + pub provider_id: String, + pub external_id: String, + pub title: Option, + pub overview: Option, + pub tagline: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub release_year: Option, + pub media_type: Option, + pub relation_kind: String, + pub match_state: String, + pub logo_url: Option, + pub cached_logo_path: Option, + pub genres_json: Option, + pub rating: Option, + pub content_rating: Option, + pub locale_key: String, + pub provider_locale_key: Option, + pub cached_artwork_path: Option, + pub cached_backdrop_path: Option, + pub refresh_state: String, + pub refresh_interval_seconds: i64, + pub last_refreshed_at: Option, + pub next_refresh_at: Option, + pub refresh_error: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_links)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataLink { + pub media_item_id: i32, + pub provider_id: String, + pub external_id: String, + pub title: Option, + pub overview: Option, + pub tagline: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub release_year: Option, + pub media_type: Option, + pub relation_kind: String, + pub match_state: String, + pub logo_url: Option, + pub cached_logo_path: Option, + pub genres_json: Option, + pub rating: Option, + pub content_rating: Option, + pub locale_key: String, + pub provider_locale_key: Option, + pub cached_artwork_path: Option, + pub cached_backdrop_path: Option, + pub refresh_state: String, + pub refresh_interval_seconds: i64, + pub last_refreshed_at: Option, + pub next_refresh_at: Option, + pub refresh_error: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(table_name = item_metadata_external_ids)] +pub struct ItemMetadataExternalId { + pub id: i32, + pub metadata_link_id: i32, + pub source: String, + pub external_id: String, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_external_ids)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataExternalId { + pub metadata_link_id: i32, + pub source: String, + pub external_id: String, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = metadata_extras)] +pub struct MetadataExtra { + pub id: i32, + pub metadata_link_id: Option, + pub collection_id: Option, + pub external_media_id: i32, + pub extra_type: String, + pub title: Option, + pub sort_order: i32, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_extras)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataExtra { + pub metadata_link_id: Option, + pub collection_id: Option, + pub external_media_id: i32, + pub extra_type: String, + pub title: Option, + pub sort_order: i32, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(table_name = item_metadata_people)] +pub struct ItemMetadataPerson { + pub id: i32, + pub metadata_link_id: i32, + pub external_id: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub profile_url: Option, + pub image_url: Option, + pub sort_order: i32, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = item_metadata_people)] +#[diesel(treat_none_as_null = true)] +pub struct NewItemMetadataPerson { + pub metadata_link_id: i32, + pub external_id: Option, + pub name: String, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub profile_url: Option, + pub image_url: Option, + pub sort_order: i32, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = metadata_people)] +pub struct MetadataPerson { + pub id: i32, + pub provider_id: String, + pub external_id: Option, + pub locale_key: String, + pub name: String, + pub known_for_json: Option, + pub biography: Option, + pub gender: Option, + pub birthday: Option, + pub deathday: Option, + pub birth_place: Option, + pub profile_url: Option, + pub image_url: Option, + pub cached_image_path: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_people)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataPerson { + pub provider_id: String, + pub external_id: Option, + pub locale_key: String, + pub name: String, + pub known_for_json: Option, + pub biography: Option, + pub gender: Option, + pub birthday: Option, + pub deathday: Option, + pub birth_place: Option, + pub profile_url: Option, + pub image_url: Option, + pub cached_image_path: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(MetadataPerson, foreign_key = person_id))] +#[diesel(table_name = metadata_person_external_ids)] +pub struct MetadataPersonExternalId { + pub id: i32, + pub person_id: i32, + pub source: String, + pub external_id: String, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_person_external_ids)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataPersonExternalId { + pub person_id: i32, + pub source: String, + pub external_id: String, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(belongs_to(MetadataPerson, foreign_key = person_id))] +#[diesel(table_name = metadata_person_credits)] +pub struct MetadataPersonCredit { + pub id: i32, + pub metadata_link_id: i32, + pub person_id: i32, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub sort_order: i32, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_person_credits)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataPersonCredit { + pub metadata_link_id: i32, + pub person_id: i32, + pub role: Option, + pub department: Option, + pub character_name: Option, + pub sort_order: i32, +} + +#[derive(Queryable, Selectable, Identifiable, Debug, Clone)] +#[diesel(table_name = metadata_collections)] +pub struct MetadataCollection { + pub id: i32, + pub provider_id: String, + pub external_id: String, + pub source_provider_id: String, + pub source_external_id: String, + pub relation_kind: String, + pub locale_key: String, + pub provider_locale_key: Option, + pub name: Option, + pub overview: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_collections)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataCollection { + pub provider_id: String, + pub external_id: String, + pub source_provider_id: String, + pub source_external_id: String, + pub relation_kind: String, + pub locale_key: String, + pub provider_locale_key: Option, + pub name: Option, + pub overview: Option, + pub artwork_url: Option, + pub backdrop_url: Option, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug, Clone)] +#[diesel(belongs_to(MetadataCollection, foreign_key = collection_id))] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(belongs_to(ItemMetadataLink, foreign_key = metadata_link_id))] +#[diesel(table_name = metadata_collection_items)] +pub struct MetadataCollectionItem { + pub id: i32, + pub collection_id: i32, + pub media_item_id: i32, + pub metadata_link_id: i32, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = metadata_collection_items)] +#[diesel(treat_none_as_null = true)] +pub struct NewMetadataCollectionItem { + pub collection_id: i32, + pub media_item_id: i32, + pub metadata_link_id: i32, + pub updated_at: Option, +} + +#[derive(Queryable, Selectable, Identifiable, Associations, Debug)] +#[diesel(belongs_to(MediaItem, foreign_key = media_item_id))] +#[diesel(table_name = playback_progress)] +pub struct PlaybackProgress { + pub id: i32, + pub user_id: Option, + pub media_item_id: i32, + pub position_ms: i64, + pub duration_ms: Option, + pub completed: bool, + pub watch_count: i32, + pub last_watched_at: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = playback_progress)] +#[diesel(treat_none_as_null = true)] +pub struct NewPlaybackProgress { + pub user_id: i32, + pub media_item_id: i32, + pub position_ms: i64, + pub duration_ms: Option, + pub completed: bool, + pub watch_count: i32, + pub last_watched_at: Option, + pub updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_files)] +#[diesel(treat_none_as_null = true)] +pub struct NewMediaFile { + pub path: String, + pub file_size: i64, + pub modified_at: Option, + pub media_kind: String, + pub file_hash: String, + pub container: Option, + pub duration_ms: Option, + pub bit_rate: Option, + pub width: Option, + pub height: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub metadata_json: Option, + pub metadata_updated_at: Option, +} + +#[derive(Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = media_file_libraries)] +#[diesel(treat_none_as_null = true)] +pub struct NewMediaFileLibrary { + pub media_file_id: i32, + pub library_id: i32, + pub source_root_path: String, + pub relative_path: String, + pub display_title: Option, + pub metadata_match_attempted_at: Option, + pub media_item_id: Option, + pub missing_since: Option, + pub deleted_at: Option, } diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs index a9831b63..afd17f86 100644 --- a/crates/server/src/db/schema.rs +++ b/crates/server/src/db/schema.rs @@ -1,7 +1,319 @@ //! Database schema for the application. // lib imports -use diesel::table; +use diesel::{ + allow_tables_to_appear_in_same_query, + joinable, + table, +}; + +table! { + app_settings (key) { + key -> Text, + value -> Text, + updated_at -> Nullable, + } +} + +table! { + external_media (id) { + id -> Integer, + source -> Text, + external_id -> Nullable, + url -> Text, + media_kind -> Text, + title -> Nullable, + duration_seconds -> Nullable, + thumbnail_url -> Nullable, + updated_at -> Nullable, + } +} + +table! { + item_metadata_external_ids (id) { + id -> Integer, + metadata_link_id -> Integer, + source -> Text, + external_id -> Text, + updated_at -> Nullable, + } +} + +table! { + item_metadata_links (id) { + id -> Integer, + media_item_id -> Integer, + provider_id -> Text, + external_id -> Text, + title -> Nullable, + overview -> Nullable, + tagline -> Nullable, + artwork_url -> Nullable, + backdrop_url -> Nullable, + release_year -> Nullable, + media_type -> Nullable, + relation_kind -> Text, + match_state -> Text, + logo_url -> Nullable, + cached_logo_path -> Nullable, + genres_json -> Nullable, + rating -> Nullable, + content_rating -> Nullable, + locale_key -> Text, + provider_locale_key -> Nullable, + cached_artwork_path -> Nullable, + cached_backdrop_path -> Nullable, + refresh_state -> Text, + refresh_interval_seconds -> BigInt, + last_refreshed_at -> Nullable, + next_refresh_at -> Nullable, + refresh_error -> Nullable, + updated_at -> Nullable, + } +} + +table! { + metadata_collections (id) { + id -> Integer, + provider_id -> Text, + external_id -> Text, + source_provider_id -> Text, + source_external_id -> Text, + relation_kind -> Text, + locale_key -> Text, + provider_locale_key -> Nullable, + name -> Nullable, + overview -> Nullable, + artwork_url -> Nullable, + backdrop_url -> Nullable, + updated_at -> Nullable, + } +} + +table! { + metadata_collection_items (id) { + id -> Integer, + collection_id -> Integer, + media_item_id -> Integer, + metadata_link_id -> Integer, + updated_at -> Nullable, + } +} + +table! { + metadata_extras (id) { + id -> Integer, + metadata_link_id -> Nullable, + collection_id -> Nullable, + external_media_id -> Integer, + extra_type -> Text, + title -> Nullable, + sort_order -> Integer, + updated_at -> Nullable, + } +} + +table! { + item_metadata_people (id) { + id -> Integer, + metadata_link_id -> Integer, + external_id -> Nullable, + name -> Text, + role -> Nullable, + department -> Nullable, + character_name -> Nullable, + profile_url -> Nullable, + image_url -> Nullable, + sort_order -> Integer, + } +} + +table! { + metadata_people (id) { + id -> Integer, + provider_id -> Text, + external_id -> Nullable, + locale_key -> Text, + name -> Text, + known_for_json -> Nullable, + biography -> Nullable, + gender -> Nullable, + birthday -> Nullable, + deathday -> Nullable, + birth_place -> Nullable, + profile_url -> Nullable, + image_url -> Nullable, + cached_image_path -> Nullable, + updated_at -> Nullable, + } +} + +table! { + metadata_person_external_ids (id) { + id -> Integer, + person_id -> Integer, + source -> Text, + external_id -> Text, + updated_at -> Nullable, + } +} + +table! { + metadata_person_credits (id) { + id -> Integer, + metadata_link_id -> Integer, + person_id -> Integer, + role -> Nullable, + department -> Nullable, + character_name -> Nullable, + sort_order -> Integer, + } +} + +table! { + media_files (id) { + id -> Integer, + path -> Text, + file_size -> BigInt, + modified_at -> Nullable, + media_kind -> Text, + file_hash -> Text, + container -> Nullable, + duration_ms -> Nullable, + bit_rate -> Nullable, + width -> Nullable, + height -> Nullable, + video_codec -> Nullable, + audio_codec -> Nullable, + metadata_json -> Nullable, + metadata_updated_at -> Nullable, + } +} + +table! { + media_file_libraries (id) { + id -> Integer, + media_file_id -> Integer, + library_id -> Integer, + source_root_path -> Text, + relative_path -> Text, + display_title -> Nullable, + metadata_match_attempted_at -> Nullable, + media_item_id -> Nullable, + missing_since -> Nullable, + deleted_at -> Nullable, + } +} + +table! { + media_items (id) { + id -> Integer, + library_id -> Integer, + parent_id -> Nullable, + identity_key -> Text, + item_type -> Text, + display_title -> Text, + relative_path -> Nullable, + media_kind -> Nullable, + season_number -> Nullable, + episode_number -> Nullable, + child_count -> Integer, + playable -> Bool, + file_size -> Nullable, + duration_ms -> Nullable, + modified_at -> Nullable, + created_at -> Nullable, + updated_at -> Nullable, + missing_since -> Nullable, + deleted_at -> Nullable, + } +} + +table! { + media_libraries (id) { + id -> Integer, + name -> Text, + path -> Text, + paths_json -> Text, + kind -> Text, + scanner -> Text, + recursive -> Bool, + metadata_providers_json -> Text, + metadata_language_mode -> Text, + metadata_languages_json -> Text, + allowed_user_ids_json -> Text, + } +} + +table! { + playback_progress (id) { + id -> Integer, + user_id -> Nullable, + media_item_id -> Integer, + position_ms -> BigInt, + duration_ms -> Nullable, + completed -> Bool, + watch_count -> Integer, + last_watched_at -> Nullable, + updated_at -> Nullable, + } +} + +table! { + scan_state (id) { + id -> Integer, + library_id -> Integer, + last_status -> Text, + last_error -> Nullable, + scan_revision -> BigInt, + last_scanned_at -> Nullable, + total_files -> BigInt, + video_files -> BigInt, + audio_files -> BigInt, + image_files -> BigInt, + book_files -> BigInt, + other_files -> BigInt, + } +} + +joinable!(metadata_extras -> external_media (external_media_id)); +joinable!(metadata_collection_items -> item_metadata_links (metadata_link_id)); +joinable!(metadata_collection_items -> media_items (media_item_id)); +joinable!(metadata_collection_items -> metadata_collections (collection_id)); +joinable!(item_metadata_external_ids -> item_metadata_links (metadata_link_id)); +joinable!(item_metadata_links -> media_items (media_item_id)); +joinable!(item_metadata_people -> item_metadata_links (metadata_link_id)); +joinable!(metadata_person_credits -> item_metadata_links (metadata_link_id)); +joinable!(metadata_person_credits -> metadata_people (person_id)); +joinable!(metadata_person_external_ids -> metadata_people (person_id)); +joinable!(media_file_libraries -> media_files (media_file_id)); +joinable!(media_file_libraries -> media_libraries (library_id)); +joinable!(media_file_libraries -> media_items (media_item_id)); +joinable!(media_items -> media_libraries (library_id)); +joinable!(playback_progress -> users (user_id)); +joinable!(playback_progress -> media_items (media_item_id)); +joinable!(scan_state -> media_libraries (library_id)); + +allow_tables_to_appear_in_same_query!( + app_settings, + external_media, + item_metadata_external_ids, + item_metadata_links, + item_metadata_people, + metadata_collection_items, + metadata_collections, + metadata_extras, + metadata_people, + metadata_person_credits, + metadata_person_external_ids, + media_file_libraries, + media_files, + media_items, + media_libraries, + playback_progress, + scan_state, + users +); table! { users (id) { @@ -10,5 +322,8 @@ table! { password -> Text, pin -> Nullable, admin -> Bool, + birthday -> Nullable, + profile_image_path -> Nullable, + preferred_metadata_languages_json -> Text, } } diff --git a/crates/server/src/dependencies/mod.rs b/crates/server/src/dependencies/mod.rs index 17199847..39073768 100644 --- a/crates/server/src/dependencies/mod.rs +++ b/crates/server/src/dependencies/mod.rs @@ -1,6 +1,9 @@ //! Module for everything related to dependencies. -use cargo_metadata::{MetadataCommand, Package}; +use cargo_metadata::{ + MetadataCommand, + Package, +}; use std::error::Error; /// Get the dependencies from the Cargo.toml file. diff --git a/crates/server/src/globals.rs b/crates/server/src/globals.rs index d40035e5..bf6a7a22 100644 --- a/crates/server/src/globals.rs +++ b/crates/server/src/globals.rs @@ -4,10 +4,11 @@ use once_cell::sync::Lazy; // local imports -use crate::config::GLOBAL_SETTINGS; +use crate::config::current_settings; // global constants and variables pub(crate) static GLOBAL_APP_NAME: &str = "Koko"; +#[cfg(feature = "tray")] pub(crate) static GLOBAL_ICON_ICO_PATH: &str = "assets/icon.ico"; /// Environment type for the application @@ -50,7 +51,7 @@ impl AppPaths { let env = Environment::from_usize(CURRENT_ENV.load(std::sync::atomic::Ordering::Relaxed)); let base_dir = match env { Environment::Test => String::from("./test_data"), - Environment::Production => GLOBAL_SETTINGS.general.data_dir.clone(), + Environment::Production => current_settings().general.data_dir, }; std::fs::create_dir_all(&base_dir).unwrap(); @@ -64,10 +65,11 @@ impl AppPaths { /// Get the server URL based on the global settings. pub fn get_server_url() -> String { - let schema = if GLOBAL_SETTINGS.server.use_https { "https" } else { "http" }; + let settings = current_settings(); + let schema = if settings.server.use_https { "https" } else { "http" }; format!( "{}://{}:{}", - schema, GLOBAL_SETTINGS.server.address, GLOBAL_SETTINGS.server.port + schema, settings.server.address, settings.server.port ) } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f1409d76..31fce3cf 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -11,13 +11,21 @@ pub mod db; pub mod dependencies; pub mod globals; mod logging; +pub mod media; +pub mod metadata; +pub mod scanner; +pub mod scheduled_tasks; +mod secrets; pub mod signal_handler; +pub mod transcode; +#[cfg(feature = "tray")] pub mod tray; +pub mod utils; pub mod web; /// Main entry point for the application. /// Initializes logging, the web server, and tray icon. -#[cfg(not(tarpaulin_include))] +#[cfg(all(not(tarpaulin_include), feature = "tray"))] pub fn main() { logging::init().expect("Failed to initialize logging"); @@ -47,3 +55,17 @@ pub fn main() { log::info!("Application shutdown complete"); } + +/// Main entry point for the application without tray support. +/// Initializes logging and runs the web server on the main thread. +#[cfg(all(not(tarpaulin_include), not(feature = "tray")))] +pub fn main() { + logging::init().expect("Failed to initialize logging"); + log::info!("Starting without tray support"); + + let runtime = + tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for web server"); + runtime.block_on(web::launch_with_shutdown( + signal_handler::ShutdownSignal::new(), + )); +} diff --git a/crates/server/src/logging/mod.rs b/crates/server/src/logging/mod.rs index f650c9df..5b532d60 100644 --- a/crates/server/src/logging/mod.rs +++ b/crates/server/src/logging/mod.rs @@ -2,9 +2,13 @@ // standard imports use std::io; +use std::path::Path; // lib imports -use fern::colors::{Color, ColoredLevelConfig}; +use fern::colors::{ + Color, + ColoredLevelConfig, +}; use log::LevelFilter; use regex::Regex; @@ -42,8 +46,13 @@ impl Logger { fn format_message( &self, message: &str, + flatten_newlines: bool, ) -> String { - let mut msg = message.to_string(); + let mut msg = if flatten_newlines { + message.replace("\r\n", " ↩ ").replace(['\n', '\r'], " ↩ ") + } else { + message.replace("\r\n", "\n").replace('\r', "\n") + }; for pattern in &self.sensitive_data_patterns { msg = pattern.replace_all(&msg, self.replace_str).to_string(); } @@ -57,18 +66,27 @@ impl Logger { record: &log::Record, remove_ansi: bool, ) { - let mut msg = self.format_message(message); + let mut msg = self.format_message(message, false); if remove_ansi { msg = self.ansi_escape.replace_all(&msg, "").to_string(); } + let module = record.module_path().unwrap_or(record.target()); + let file = normalize_log_source_path(record.file().unwrap_or("unknown")); + let line = record + .line() + .map(|value| value.to_string()) + .unwrap_or_else(|| "?".into()); out.finish(format_args!( - "{} [{}] {}", + "{} [{}] [{}] [{}:{}] {}", chrono::Local::now().format(self.time_format), if remove_ansi { record.level().to_string() } else { self.colors.color(record.level()).to_string() }, + module, + file, + line, msg )); } @@ -100,6 +118,74 @@ impl Logger { } } +fn normalize_path_separators(path: &str) -> String { + path.trim().replace('\\', "/") +} + +fn shorten_absolute_path( + path: &str, + segments: usize, +) -> String { + let parts = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if parts.len() <= segments { + return parts.join("/"); + } + + parts[parts.len().saturating_sub(segments)..].join("/") +} + +fn workspace_relative_path(path: &str) -> Option { + let manifest_dir = normalize_path_separators(env!("CARGO_MANIFEST_DIR")); + let manifest_path = Path::new(&manifest_dir); + let repo_root = manifest_path + .parent() + .and_then(Path::parent) + .map(|path| normalize_path_separators(&path.to_string_lossy()))?; + + [repo_root, manifest_dir].into_iter().find_map(|prefix| { + let normalized_prefix = prefix.trim_end_matches('/'); + path.strip_prefix(&format!("{normalized_prefix}/")) + .map(|value| value.to_string()) + .or_else(|| (path == normalized_prefix).then(String::new)) + }) +} + +pub fn normalize_display_path(path: &str) -> String { + normalize_path_separators(path) +} + +pub fn normalize_log_source_path(path: &str) -> String { + let normalized = normalize_path_separators(path); + if normalized.is_empty() { + return "unknown".into(); + } + + if let Some(relative) = workspace_relative_path(&normalized) { + return relative; + } + + if let Some((_, remainder)) = normalized.split_once("/.cargo/registry/src/") { + if let Some((_, crate_relative)) = remainder.split_once('/') { + return crate_relative.to_string(); + } + } + + if let Some((_, remainder)) = normalized.split_once("/cargo/registry/src/") { + if let Some((_, crate_relative)) = remainder.split_once('/') { + return crate_relative.to_string(); + } + } + + if normalized.contains(":/") || normalized.starts_with('/') { + return shorten_absolute_path(&normalized, 4); + } + + normalized +} + pub fn init() -> Result<(), Box> { let logger = Logger::new()?; logger.init() diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs new file mode 100644 index 00000000..bff88d5a --- /dev/null +++ b/crates/server/src/media.rs @@ -0,0 +1,6446 @@ +//! Media-library inspection, persistence, and transcoding capability utilities. + +// standard imports +use std::collections::{ + HashMap, + HashSet, +}; +use std::fs; +use std::path::{ + Path, + PathBuf, +}; +use std::process::Command; + +// lib imports +use diesel::{ + ExpressionMethods, + OptionalExtension, + QueryDsl, + RunQueryDsl, + SelectableHelper, + SqliteConnection, + sql_types, +}; +use schemars::JsonSchema; +use serde::{ + Deserialize, + Serialize, +}; +use serde_json::Value; + +// local imports +use crate::config::{ + FfmpegSettings, + MediaLibraryKind, + MediaLibraryMetadataLanguageMode, + MediaLibraryScanner, + MediaLibrarySettings, + MetadataProviderId, +}; +use crate::db::models::{ + ItemMetadataLink, + MediaFile, + MediaItem, + MediaLibrary, + MetadataCollection, + MetadataCollectionItem, + NewMediaFile, + NewMediaFileLibrary, + NewMediaItem, + NewMediaLibrary, + NewPlaybackProgress, + NewScanState, + PlaybackProgress, + ScanState, + User, +}; +use crate::metadata::{ + ArtworkKind, + DEFAULT_METADATA_LOCALE, + LinkedMetadataExtra, + MetadataCollectionSummary, + MetadataProvider, + MetadataRegistry, + list_metadata_collection_summaries_with_preferred_languages, + metadata_extras_from_metadata_links, + normalize_locale_key, + presentation_from_metadata_links, +}; +use crate::scanner::shows::parse_show_path; +pub use crate::scanner::shows::{ + infer_episode_number, + infer_season_number, +}; +use crate::scanner::{ + DiscoveredMediaFile, + FileHashCandidate, + ScannerSink, + fallback_title_from_relative_path, + inspect_library, + inspect_library_streaming, +}; +pub use crate::scanner::{ + LibraryScanStatus, + LibraryScanSummary, +}; +use crate::utils::current_timestamp; + +#[derive(Debug)] +struct SecondaryMetadataReferenceCandidate { + item_type: String, + database_id: String, + external_id: String, + priority: usize, + order: usize, +} + +#[derive(Debug, Clone)] +struct SummaryMetadataLink { + media_item_id: i32, + title: Option, + overview: Option, + genres_json: Option, + logo_url: Option, + cached_logo_path: Option, + backdrop_url: Option, + cached_backdrop_path: Option, + refresh_state: String, + refresh_error: Option, + updated_at: Option, + locale_key: String, +} + +/// Details about a discovered executable used for media processing. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct BinaryCapability { + /// Configured command or path. + pub configured_path: String, + /// Whether the executable could be launched successfully. + pub available: bool, + /// First line of the version output, when available. + pub version: Option, + /// Error details when the executable is unavailable. + pub error: Option, +} + +/// Current FFmpeg tooling availability. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct TranscodingCapability { + /// FFmpeg executable capability. + pub ffmpeg: BinaryCapability, + /// ffprobe executable capability. + pub ffprobe: BinaryCapability, +} + +/// Persisted media library summary with a stable database identity. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PersistedLibrarySummary { + /// Stable database identifier for the library. + pub id: i32, + /// Human-friendly library name. + pub name: String, + /// Configured filesystem path. + pub path: String, + /// Configured filesystem paths for this logical library. + pub paths: Vec, + /// Whether the scan is recursive. + pub recursive: bool, + /// Intended media category for the library. + pub kind: MediaLibraryKind, + /// Scanner used for the library inventory. + pub scanner: MediaLibraryScanner, + /// Ordered metadata providers configured for this library. + pub metadata_providers: Vec, + /// Whether metadata languages are inferred from users or set manually. + pub metadata_language_mode: MediaLibraryMetadataLanguageMode, + /// Ordered metadata languages configured for this library. + pub metadata_languages: Vec, + /// Scan status for this library. + pub status: LibraryScanStatus, + /// Monotonically increasing scan revision. + pub scan_revision: i64, + /// Last completed scan time as Unix seconds, when available. + pub last_scanned_at: Option, + /// Total number of files discovered. + pub total_files: i64, + /// Number of video files discovered. + pub video_files: i64, + /// Number of audio files discovered. + pub audio_files: i64, + /// Number of image files discovered. + pub image_files: i64, + /// Number of book or document files discovered. + pub book_files: i64, + /// Number of files that do not match known media extensions. + pub other_files: i64, + /// The last scan error, if any. + pub error: Option, + /// Number of linked metadata items tracked for refresh progress. + pub metadata_refresh_total: i64, + /// Number of linked metadata items still pending refresh. + pub metadata_refresh_pending: i64, + /// Number of linked metadata items already processed in the active refresh run. + pub metadata_refresh_completed: i64, + /// Number of linked metadata items whose latest refresh failed. + pub metadata_refresh_failed: i64, + /// Number of file rows currently marked missing. + pub missing_files: i64, + /// Number of item rows currently marked missing. + pub missing_items: i64, +} + +#[derive(Debug, Clone, Default)] +struct LibraryMetadataRefreshCounts { + total_items: i64, + pending_items: i64, + completed_items: i64, + failed_items: i64, +} + +const CATALOG_MEDIA_FILE_COLUMNS: &str = + "\ + files.id AS id,files.path AS path,memberships.id AS library_file_id,memberships.library_id \ + AS library_id,memberships.source_root_path AS source_root_path,memberships.relative_path AS \ + relative_path,files.file_size AS file_size,files.modified_at AS modified_at,files.media_kind \ + AS media_kind,files.file_hash AS file_hash,memberships.display_title AS \ + display_title,files.container AS container,files.duration_ms AS duration_ms,files.bit_rate \ + AS bit_rate,files.width AS width,files.height AS height,files.video_codec AS \ + video_codec,files.audio_codec AS audio_codec,files.metadata_json AS \ + metadata_json,files.metadata_updated_at AS \ + metadata_updated_at,memberships.metadata_match_attempted_at AS \ + metadata_match_attempted_at,memberships.media_item_id AS \ + media_item_id,memberships.missing_since AS missing_since,memberships.deleted_at AS deleted_at"; + +/// Persisted media file summary for a library. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PersistedMediaFileSummary { + /// Stable database identifier for the file row. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Library-relative file path. + pub relative_path: String, + /// File size in bytes. + pub file_size: i64, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, + /// Classified media type for the file. + pub media_kind: String, + /// Scanner-owned file hash used to detect when probing should be refreshed. + pub file_hash: String, + /// Browser-friendly title for the item. + pub display_title: String, + /// Container format reported by ffprobe when available. + pub container: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Video codec name when available. + pub video_codec: Option, + /// Audio codec name when available. + pub audio_codec: Option, + /// When this file was first observed as missing from disk. + pub missing_since: Option, +} + +/// Summary of a browser-visible media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaItemSummary { + /// Stable database identifier for the item. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Parent item identifier when this item belongs to a hierarchy. + pub parent_id: Option, + /// Logical item type such as movie, show, season, or episode. + pub item_type: String, + /// Display title for the item. + pub display_title: String, + /// Optional card subtitle override for shelf-specific presentation. + pub display_subtitle: Option, + /// Optional item id to use as the poster/backdrop artwork source. + pub artwork_item_id: Option, + /// Library-relative file path. + pub relative_path: String, + /// Classified media kind. + pub media_kind: String, + /// Whether the item can be played directly as a leaf item. + pub playable: bool, + /// Number of direct child items. + pub child_count: i32, + /// Number of show seasons that currently have at least one available episode. + pub available_season_count: Option, + /// Season number when the item is a season or episode. + pub season_number: Option, + /// Episode number when the item is an episode. + pub episode_number: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Genre labels from linked metadata when available. + pub genres: Vec, + /// Description or overview from linked metadata, when available. + pub overview: Option, + /// Local or managed backdrop artwork URL, when available. + pub backdrop_url: Option, + /// Local or managed title logo URL, when available. + pub logo_url: Option, + /// Whether the item currently has linked metadata. + pub has_metadata: bool, + /// Current metadata refresh state when metadata exists. + pub metadata_refresh_state: Option, + /// Last metadata refresh error, when available. + pub metadata_refresh_error: Option, + /// Revision timestamp for artwork cache-busting when linked metadata changes. + pub artwork_updated_at: Option, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, + /// Last saved playback position for the current user. + pub playback_position_ms: Option, + /// Last saved playback duration for the current user. + pub playback_duration_ms: Option, + /// Whether the current user's saved playback row is complete. + pub playback_completed: bool, + /// Number of times the current user has completed this item. + pub watch_count: i32, + /// Last time the current user completed this item as Unix seconds. + pub last_watched_at: Option, + /// When this item was first observed as missing from disk. + pub missing_since: Option, +} + +/// Episode target selected for container-level playback actions. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaPlaybackTarget { + /// Playable episode item identifier. + pub item_id: i32, + /// Start position in milliseconds. + pub start_ms: i64, + /// Human-friendly action label for the button. + pub label: String, + /// Episode title for display and accessibility. + pub display_title: String, + /// Season number when known. + pub season_number: Option, + /// Episode number when known. + pub episode_number: Option, + /// Whether the target resumes an unfinished episode. + pub resume: bool, +} + +/// Detailed browser-facing media item response. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +pub struct MediaItemDetail { + /// Stable database identifier for the item. + pub id: i32, + /// Owning media-library identifier. + pub library_id: i32, + /// Parent item identifier when this item belongs to a hierarchy. + pub parent_id: Option, + /// Logical item type such as movie, show, season, or episode. + pub item_type: String, + /// Display title for the item. + pub display_title: String, + /// Library-relative file path. + pub relative_path: String, + /// File size in bytes. + pub file_size: Option, + /// Last modified timestamp as Unix seconds, when available. + pub modified_at: Option, + /// Classified media kind. + pub media_kind: String, + /// Whether the item can be played directly as a leaf item. + pub playable: bool, + /// Number of direct child items. + pub child_count: i32, + /// Number of show seasons that currently have at least one available episode. + pub available_season_count: Option, + /// Season number when the item is a season or episode. + pub season_number: Option, + /// Episode number when the item is an episode. + pub episode_number: Option, + /// Container format reported by ffprobe when available. + pub container: Option, + /// Duration in milliseconds when available. + pub duration_ms: Option, + /// Bit rate when available. + pub bit_rate: Option, + /// Video width when available. + pub width: Option, + /// Video height when available. + pub height: Option, + /// Video codec name when available. + pub video_codec: Option, + /// Audio codec name when available. + pub audio_codec: Option, + /// Raw ffprobe JSON payload, when available. + pub metadata_json: Option, + /// Metadata update timestamp as Unix seconds, when available. + pub metadata_updated_at: Option, + /// Local or managed poster artwork URL, when available. + pub poster_url: Option, + /// Local or managed backdrop artwork URL, when available. + pub backdrop_url: Option, + /// Theme-song URL, when available. + pub theme_song_url: Option, + /// Tagline from linked metadata, when available. + pub tagline: Option, + /// Description or overview from linked metadata, when available. + pub overview: Option, + /// Genre labels from linked metadata. + pub genres: Vec, + /// Release year from linked metadata, when available. + pub release_year: Option, + /// Provider-supplied title logo URL, when available. + pub logo_url: Option, + /// Provider-supplied user/community rating, when available. + pub rating: Option, + /// Provider-supplied content rating such as PG-13 or TV-MA, when available. + pub content_rating: Option, + /// Linked metadata media type such as movie or tv. + pub linked_media_type: Option, + /// Whether the item currently has linked metadata. + pub has_metadata: bool, + /// Current metadata refresh state when metadata exists. + pub metadata_refresh_state: Option, + /// Last metadata refresh error, when available. + pub metadata_refresh_error: Option, + /// Revision timestamp for artwork cache-busting when linked metadata changes. + pub artwork_updated_at: Option, + /// Trailer title, when available. + pub trailer_title: Option, + /// Trailer URL, when available. + pub trailer_url: Option, + /// Provider-supplied external media extras for this item. + pub extras: Vec, + /// Audio streams discovered in the source container. + pub audio_tracks: Vec, + /// Discovered subtitle sidecars for this item. + pub subtitle_tracks: Vec, + /// Breadcrumb-like hierarchy for this item. + pub hierarchy: Vec, + /// Direct child items for hierarchical browsing. + pub children: Vec, + /// Playable target for non-playable containers such as shows or seasons. + pub playback_target: Option, + /// Optional target for restarting a container from its first episode. + pub restart_playback_target: Option, + /// Last saved playback position for the current user. + pub playback_position_ms: Option, + /// Last saved playback duration for the current user. + pub playback_duration_ms: Option, + /// Whether the current user's saved playback row is complete. + pub playback_completed: bool, + /// Number of times the current user has completed this item. + pub watch_count: i32, + /// Last time the current user completed this item as Unix seconds. + pub last_watched_at: Option, + /// When this item was first observed as missing from disk. + pub missing_since: Option, +} + +/// Provider-known season metadata that may not have a local library row yet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShowMetadataSeasonPlan { + /// Season number from the provider. + pub season_number: i32, + /// Optional provider title to use until full metadata is refreshed. + pub display_title: Option, +} + +/// Provider-known episode metadata that may not have a local library row yet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShowMetadataEpisodePlan { + /// Season number from the provider. + pub season_number: i32, + /// Episode number from the provider. + pub episode_number: i32, + /// Optional provider title to use until full metadata is refreshed. + pub display_title: Option, +} + +/// Provider-known descendants for one linked show. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ShowMetadataDescendantPlan { + /// Seasons to keep visible and metadata-refreshable. + pub seasons: Vec, + /// Episodes to keep visible and metadata-refreshable when their season has local episodes. + pub episodes: Vec, +} + +/// Local rows created or found for provider-known show descendants. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ShowMetadataDescendantItems { + /// Season item rows keyed by season number. + pub seasons_by_number: HashMap, + /// Episode item rows keyed by `(season_number, episode_number)`. + pub episodes_by_number: HashMap<(i32, i32), MediaItemSummary>, + /// Seasons where at least one playable local episode exists, so all provider episodes were + /// materialized. + pub seasons_with_local_episodes: HashSet, +} + +/// External media extra exposed on item details. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaItemExtra { + /// Koko extra type such as `trailer`, `clip`, or `theme_song`. + pub extra_type: String, + /// Human-friendly title, when available. + pub title: Option, + /// External media URL. + pub url: String, + /// Duration in seconds, when known. + pub duration_seconds: Option, + /// Thumbnail URL, when available. + pub thumbnail_url: Option, +} + +impl From for MediaItemExtra { + fn from(extra: LinkedMetadataExtra) -> Self { + Self { + extra_type: extra.extra_type, + title: extra.title, + url: extra.url, + duration_seconds: extra.duration_seconds, + thumbnail_url: extra.thumbnail_url, + } + } +} + +/// Result of removing missing items from the active catalog. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MissingItemsCleanupSummary { + /// Library scoped by the cleanup request, when applicable. + pub library_id: Option, + /// File rows removed from the active catalog. + pub deleted_files: i64, + /// Item rows removed from the active catalog. + pub deleted_items: i64, + /// Collection membership rows removed from active collection/list views. + pub removed_collection_items: i64, +} + +/// One audio stream discovered inside a media file. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaAudioTrack { + /// Zero-based audio stream index among audio streams, suitable for `0:a:`. + pub index: usize, + /// Human-friendly label. + pub label: String, + /// Codec name when available. + pub codec: Option, + /// Stream language when available. + pub language: Option, + /// Whether the stream is marked as default. + pub default: bool, +} + +/// Subtitle track discovered for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaSubtitleTrack { + /// Stable index used to request the subtitle asset. + pub index: usize, + /// Human-friendly track label. + pub label: String, + /// Subtitle container or format. + pub format: String, + /// Browser-facing asset URL. + pub url: String, +} + +/// One media shelf on the browser home screen. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaShelf { + /// Stable shelf identifier. + pub id: String, + /// Shelf title. + pub title: String, + /// Items shown in the shelf. + pub items: Vec, +} + +/// Browser-facing home response. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MediaHome { + /// Library filter currently applied. + pub library_id: Option, + /// Kodi/Plex-style shelves for the main page. + pub shelves: Vec, + /// Real collection groupings derived from linked metadata. + pub collections: Vec, +} + +/// Codec/container/format capabilities that a client declares to the server. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +pub struct ClientProfile { + /// Unique client type identifier (e.g. "web", "android", "desktop-win"). + pub client_type: String, + /// Human-readable client name for logging/UI. + pub client_name: String, + /// Containers the client can play natively (e.g. ["mp4", "webm", "matroska"]). + pub supported_containers: Vec, + /// Video codecs the client can decode (e.g. ["h264", "av1", "vp9", "hevc"]). + pub supported_video_codecs: Vec, + /// Audio codecs the client can decode (e.g. ["aac", "opus", "mp3", "flac"]). + pub supported_audio_codecs: Vec, + /// Subtitle formats the client can render (e.g. ["srt", "vtt", "ass"]). + pub supported_subtitle_formats: Vec, + /// Maximum video width the client wants to receive (0 = no limit). + pub max_video_width: u32, + /// Maximum video height the client wants to receive (0 = no limit). + pub max_video_height: u32, + /// Maximum total bitrate in kbps the client wants to receive (0 = no limit). + pub max_bitrate_kbps: u32, + /// Whether the client can handle adaptive bitrate streams (HLS/DASH). + pub supports_adaptive_streaming: bool, + /// Whether the client prefers HLS over raw progressive download. + pub prefer_hls: bool, +} + +/// Direct-play versus transcode decision for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct PlaybackDecision { + /// Stable database identifier for the item. + pub item_id: i32, + /// Whether the item can be played directly in the browser. + pub can_direct_play: bool, + /// Whether transcoding would be required for ideal playback. + pub transcode_required: bool, + /// Human-readable reason for the current decision. + pub reason: String, + /// Direct stream URL when direct play is supported. + pub stream_url: Option, + /// Browser media MIME type when known. + pub mime_type: Option, + /// When transcode is required, the target container. + pub transcode_container: Option, + /// When transcode is required, the target video codec. + pub transcode_video_codec: Option, + /// When transcode is required, the target audio codec. + pub transcode_audio_codec: Option, + /// Whether only the video track needs transcoding. + pub video_transcode_required: bool, + /// Whether only the audio track needs transcoding. + pub audio_transcode_required: bool, + /// Source media info for display. + pub source_video_codec: Option, + /// Source audio codec. + pub source_audio_codec: Option, + /// Source container. + pub source_container: Option, +} + +/// Server-managed playback session tracking one active transcode or direct-play stream. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +pub struct PlaybackSession { + /// The unique identifier for this session. + pub session_id: String, + /// The ID of the item being played. + pub item_id: i32, + /// The user requesting playback, if any. + pub user_id: Option, + /// The client profile that initiated this session. + pub client_profile: ClientProfile, + /// The playback decision rendered for this session. + pub decision: PlaybackDecision, + /// Unix timestamp when the session was created. + pub created_at: i64, + /// Selected zero-based audio stream index among audio streams. + pub audio_stream_index: Option, +} + +/// One unmatched media item that is eligible for automatic metadata linking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutomaticMetadataCandidate { + /// Stable item identifier. + pub item_id: i32, + /// Library-relative media path. + pub relative_path: String, + /// Current display title derived from scan data. + pub display_title: String, + /// Last modification timestamp used to prioritize recent items. + pub modified_at: Option, + /// Owning library kind. + pub library_kind: MediaLibraryKind, + /// Metadata providers enabled for the library. + pub metadata_providers: Vec, +} + +#[derive(Debug, Clone, diesel::QueryableByName)] +#[diesel(table_name = crate::db::schema::media_files)] +struct CatalogMediaFile { + #[diesel(sql_type = sql_types::Integer)] + id: i32, + #[diesel(sql_type = sql_types::Text)] + path: String, + #[diesel(sql_type = sql_types::Integer)] + library_file_id: i32, + #[diesel(sql_type = sql_types::Integer)] + library_id: i32, + #[diesel(sql_type = sql_types::Text)] + source_root_path: String, + #[diesel(sql_type = sql_types::Text)] + relative_path: String, + #[diesel(sql_type = sql_types::BigInt)] + file_size: i64, + #[diesel(sql_type = sql_types::Nullable)] + modified_at: Option, + #[diesel(sql_type = sql_types::Text)] + media_kind: String, + #[diesel(sql_type = sql_types::Text)] + file_hash: String, + #[diesel(sql_type = sql_types::Nullable)] + display_title: Option, + #[diesel(sql_type = sql_types::Nullable)] + container: Option, + #[diesel(sql_type = sql_types::Nullable)] + duration_ms: Option, + #[diesel(sql_type = sql_types::Nullable)] + bit_rate: Option, + #[diesel(sql_type = sql_types::Nullable)] + width: Option, + #[diesel(sql_type = sql_types::Nullable)] + height: Option, + #[diesel(sql_type = sql_types::Nullable)] + video_codec: Option, + #[diesel(sql_type = sql_types::Nullable)] + audio_codec: Option, + #[diesel(sql_type = sql_types::Nullable)] + metadata_json: Option, + #[diesel(sql_type = sql_types::Nullable)] + metadata_updated_at: Option, + #[diesel(sql_type = sql_types::Nullable)] + metadata_match_attempted_at: Option, + #[diesel(sql_type = sql_types::Nullable)] + media_item_id: Option, + #[diesel(sql_type = sql_types::Nullable)] + missing_since: Option, + #[diesel(sql_type = sql_types::Nullable)] + deleted_at: Option, +} + +#[derive(Debug, Clone, Default)] +struct ExtractedMetadata { + container: Option, + duration_ms: Option, + bit_rate: Option, + width: Option, + height: Option, + video_codec: Option, + audio_codec: Option, + metadata_json: Option, + metadata_updated_at: Option, +} + +#[derive(Debug, Clone)] +struct PlannedMediaItem { + identity_key: String, + parent_identity_key: Option, + item_type: String, + display_title: String, + relative_path: Option, + media_kind: Option, + season_number: Option, + episode_number: Option, + playable: bool, + child_count: i32, + file_size: Option, + duration_ms: Option, + modified_at: Option, + explicit_id: Option, + missing_since: Option, + available_leaf_count: i32, + missing_leaf_count: i32, +} + +#[derive(Debug, Clone)] +struct PlannedLibraryItems { + items: Vec, + leaf_identity_by_file_id: HashMap, +} + +#[derive(Debug, Clone, Copy)] +struct ProbeContext<'a> { + ffprobe_path: &'a str, + enabled: bool, +} + +/// Inspect configured media libraries and return lightweight scan summaries. +pub fn inspect_libraries(libraries: &[MediaLibrarySettings]) -> Vec { + libraries + .iter() + .map(inspect_library) + .map(|inspection| inspection.summary) + .collect() +} + +/// Detect FFmpeg and ffprobe availability from the configured settings. +pub fn inspect_transcoding_capability(settings: &FfmpegSettings) -> TranscodingCapability { + TranscodingCapability { + ffmpeg: detect_binary(&settings.ffmpeg_path), + ffprobe: detect_binary(&settings.ffprobe_path), + } +} + +/// Return the number of persisted media libraries. +pub fn count_persisted_libraries( + conn: &mut SqliteConnection +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + media_libraries_dsl::media_libraries + .count() + .get_result(conn) +} + +/// Return the persisted media-library settings stored in the database. +pub fn list_library_settings( + conn: &mut SqliteConnection +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let rows = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + + Ok(rows + .into_iter() + .map(media_library_settings_from_row) + .collect()) +} + +/// Return metadata providers configured for a persisted library id. +pub fn get_library_metadata_providers( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result>, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first::(conn) + .optional()?; + + Ok(library.map(|row| media_library_settings_from_row(row).metadata_providers)) +} + +/// Return metadata languages configured for a persisted library id. +pub fn get_library_metadata_languages( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result>, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first::(conn) + .optional()?; + + let Some(row) = library else { + return Ok(None); + }; + let settings = media_library_settings_from_row(row); + if settings.metadata_language_mode == MediaLibraryMetadataLanguageMode::Manual { + return Ok(Some(settings.metadata_languages)); + } + + Ok(Some(user_metadata_languages_for_library( + conn, + &settings.allowed_user_ids, + )?)) +} + +fn user_metadata_languages_for_library( + conn: &mut SqliteConnection, + allowed_user_ids: &[i32], +) -> Result, diesel::result::Error> { + use crate::db::schema::users::dsl as users_dsl; + + let rows = users_dsl::users + .select(User::as_select()) + .load::(conn)?; + let mut languages = Vec::new(); + for user in rows { + let has_access = + allowed_user_ids.is_empty() || user.admin || allowed_user_ids.contains(&user.id); + if !has_access { + continue; + } + let preferred = + serde_json::from_str::>(&user.preferred_metadata_languages_json) + .unwrap_or_default(); + for language in preferred { + let language = normalize_locale_key(&language); + if !language.is_empty() && !languages.contains(&language) { + languages.push(language); + } + } + } + if languages.is_empty() { + languages.push(crate::metadata::DEFAULT_METADATA_LOCALE.to_string()); + } + Ok(languages) +} + +/// Return whether a user can view a library. Empty access lists are public. +pub fn user_can_access_library( + conn: &mut SqliteConnection, + library_id: i32, + user_id: Option, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + use crate::db::schema::users::dsl as users_dsl; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first::(conn) + .optional()?; + let Some(library) = library else { + return Ok(false); + }; + let settings = media_library_settings_from_row(library); + if settings.allowed_user_ids.is_empty() { + return Ok(true); + } + let Some(user_id) = user_id else { + return Ok(false); + }; + let is_admin = users_dsl::users + .filter(users_dsl::id.eq(user_id)) + .select(users_dsl::admin) + .first::(conn) + .optional()? + .unwrap_or(false); + Ok(is_admin || settings.allowed_user_ids.contains(&user_id)) +} + +/// Replace the persisted media-library settings stored in the database. +pub fn replace_library_settings( + conn: &mut SqliteConnection, + libraries: &[MediaLibrarySettings], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let existing = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + + for (index, library) in libraries.iter().enumerate() { + if let Some(existing_row) = existing.get(index) { + update_media_library(conn, existing_row.id, library)?; + } else { + insert_media_library(conn, library)?; + } + } + + for stale_library in existing.into_iter().skip(libraries.len()) { + diesel::delete( + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(stale_library.id)), + ) + .execute(conn)?; + } + + list_library_settings(conn) +} + +/// Insert one persisted media library. +pub fn add_library_setting( + conn: &mut SqliteConnection, + library: &MediaLibrarySettings, +) -> Result, diesel::result::Error> { + insert_media_library(conn, library)?; + list_library_settings(conn) +} + +/// Remove one persisted media library by its database identifier. +pub fn remove_library_setting( + conn: &mut SqliteConnection, + library_index: usize, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let existing = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + let Some(library_id) = existing.get(library_index).map(|library| library.id) else { + return Ok(false); + }; + + let deleted = diesel::delete( + media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq(library_id)), + ) + .execute(conn)?; + + Ok(deleted > 0) +} + +/// Sync persisted libraries from the database into the media catalog. +pub fn sync_persisted_library_catalog( + conn: &mut SqliteConnection, + ffmpeg_settings: &FfmpegSettings, +) -> Result, diesel::result::Error> { + let libraries = list_library_settings(conn)?; + sync_library_catalog(conn, &libraries, ffmpeg_settings) +} + +/// Sync one persisted media library from the database into the media catalog. +pub fn sync_persisted_library_catalog_for_library( + conn: &mut SqliteConnection, + ffmpeg_settings: &FfmpegSettings, + library_id: i32, +) -> Result, diesel::result::Error> { + let libraries = list_library_settings(conn)?; + sync_library_catalog_filtered(conn, &libraries, ffmpeg_settings, Some(library_id)) + .map(|mut summaries| summaries.pop()) +} + +/// Return persisted media-library summaries without triggering a foreground rescan. +pub fn get_persisted_library_summaries( + conn: &mut SqliteConnection +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + use crate::db::schema::scan_state::dsl as scan_state_dsl; + + let libraries = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + let states = scan_state_dsl::scan_state + .select(ScanState::as_select()) + .load::(conn)? + .into_iter() + .map(|state| (state.library_id, state)) + .collect::>(); + let item_library_ids = if libraries.is_empty() { + HashMap::new() + } else { + media_items_dsl::media_items + .filter( + media_items_dsl::library_id.eq_any( + libraries + .iter() + .map(|library| library.id) + .collect::>(), + ), + ) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .load::(conn)? + .into_iter() + .map(|item| (item.id, item.library_id)) + .collect::>() + }; + let refresh_counts = if item_library_ids.is_empty() { + HashMap::new() + } else { + item_metadata_links_dsl::item_metadata_links + .filter( + item_metadata_links_dsl::media_item_id + .eq_any(item_library_ids.keys().copied().collect::>()), + ) + .filter(item_metadata_links_dsl::relation_kind.eq("primary")) + .select(ItemMetadataLink::as_select()) + .load::(conn)? + .into_iter() + .fold( + HashMap::::new(), + |mut grouped, link| { + let Some(library_id) = item_library_ids.get(&link.media_item_id).copied() + else { + return grouped; + }; + + let counts = grouped.entry(library_id).or_default(); + counts.total_items += 1; + if link.refresh_state == "pending" { + counts.pending_items += 1; + } else { + counts.completed_items += 1; + if link.refresh_state == "error" { + counts.failed_items += 1; + } + } + + grouped + }, + ) + }; + let library_ids = libraries + .iter() + .map(|library| library.id) + .collect::>(); + let missing_files_by_library = if library_ids.is_empty() { + HashMap::new() + } else { + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::library_id.eq_any(&library_ids)) + .filter(media_file_libraries_dsl::deleted_at.is_null()) + .filter(media_file_libraries_dsl::missing_since.is_not_null()) + .select(media_file_libraries_dsl::library_id) + .load::(conn)? + .into_iter() + .fold(HashMap::::new(), |mut counts, library_id| { + *counts.entry(library_id).or_default() += 1; + counts + }) + }; + let missing_items_by_library = if library_ids.is_empty() { + HashMap::new() + } else { + media_items_dsl::media_items + .filter(media_items_dsl::library_id.eq_any(&library_ids)) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_not_null()) + .select(media_items_dsl::library_id) + .load::(conn)? + .into_iter() + .fold(HashMap::::new(), |mut counts, library_id| { + *counts.entry(library_id).or_default() += 1; + counts + }) + }; + + Ok(libraries + .into_iter() + .map(|library| { + let settings = media_library_settings_from_row(library.clone()); + let state = states.get(&library.id); + let metadata_counts = refresh_counts.get(&library.id).cloned().unwrap_or_default(); + let scanner = settings.scanner.effective_for_kind(&settings.kind); + PersistedLibrarySummary { + id: library.id, + name: settings.name, + path: settings.path, + paths: settings.paths, + recursive: settings.recursive, + kind: settings.kind, + scanner, + metadata_providers: settings.metadata_providers, + metadata_language_mode: settings.metadata_language_mode, + metadata_languages: settings.metadata_languages, + status: state + .map(|state| LibraryScanStatus::from_storage_value(&state.last_status)) + .unwrap_or(LibraryScanStatus::NeverScanned), + scan_revision: state.map(|state| state.scan_revision).unwrap_or_default(), + last_scanned_at: state.and_then(|state| state.last_scanned_at), + total_files: state.map(|state| state.total_files).unwrap_or_default(), + video_files: state.map(|state| state.video_files).unwrap_or_default(), + audio_files: state.map(|state| state.audio_files).unwrap_or_default(), + image_files: state.map(|state| state.image_files).unwrap_or_default(), + book_files: state.map(|state| state.book_files).unwrap_or_default(), + other_files: state.map(|state| state.other_files).unwrap_or_default(), + error: state.and_then(|state| state.last_error.clone()), + metadata_refresh_total: metadata_counts.total_items, + metadata_refresh_pending: metadata_counts.pending_items, + metadata_refresh_completed: metadata_counts.completed_items, + metadata_refresh_failed: metadata_counts.failed_items, + missing_files: missing_files_by_library + .get(&library.id) + .copied() + .unwrap_or_default(), + missing_items: missing_items_by_library + .get(&library.id) + .copied() + .unwrap_or_default(), + } + }) + .collect()) +} + +fn load_catalog_files_for_library( + conn: &mut SqliteConnection, + library_id: i32, + include_deleted: bool, +) -> Result, diesel::result::Error> { + let deleted_filter = if include_deleted { "" } else { " AND memberships.deleted_at IS NULL" }; + let sql = format!( + "SELECT {CATALOG_MEDIA_FILE_COLUMNS} FROM media_file_libraries AS memberships INNER JOIN \ + media_files AS files ON files.id = memberships.media_file_id WHERE \ + memberships.library_id = ?{deleted_filter} ORDER BY memberships.relative_path ASC" + ); + diesel::sql_query(sql) + .bind::(library_id) + .load::(conn) +} + +fn load_active_catalog_files( + conn: &mut SqliteConnection +) -> Result, diesel::result::Error> { + let sql = format!( + "SELECT {CATALOG_MEDIA_FILE_COLUMNS} FROM media_file_libraries AS memberships INNER JOIN \ + media_files AS files ON files.id = memberships.media_file_id WHERE \ + memberships.deleted_at IS NULL AND memberships.missing_since IS NULL ORDER BY \ + files.modified_at DESC, files.id ASC" + ); + diesel::sql_query(sql).load::(conn) +} + +fn load_catalog_file_for_item( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + let sql = format!( + "SELECT {CATALOG_MEDIA_FILE_COLUMNS} FROM media_file_libraries AS memberships INNER JOIN \ + media_files AS files ON files.id = memberships.media_file_id WHERE \ + memberships.media_item_id = ? AND memberships.deleted_at IS NULL ORDER BY memberships.id \ + ASC LIMIT 1" + ); + diesel::sql_query(sql) + .bind::(item_id) + .load::(conn) + .map(|mut rows| rows.pop()) +} + +fn load_catalog_file_for_library_path( + conn: &mut SqliteConnection, + library_id: i32, + source_root_path: &str, + relative_path: &str, +) -> Result, diesel::result::Error> { + let sql = format!( + "SELECT {CATALOG_MEDIA_FILE_COLUMNS} FROM media_file_libraries AS memberships INNER JOIN \ + media_files AS files ON files.id = memberships.media_file_id WHERE \ + memberships.library_id = ? AND memberships.source_root_path = ? AND \ + memberships.relative_path = ? ORDER BY memberships.id ASC LIMIT 1" + ); + diesel::sql_query(sql) + .bind::(library_id) + .bind::(source_root_path) + .bind::(relative_path) + .load::(conn) + .map(|mut rows| rows.pop()) +} + +fn load_media_file_by_path( + conn: &mut SqliteConnection, + path: &str, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_files::dsl as media_files_dsl; + + media_files_dsl::media_files + .filter(media_files_dsl::path.eq(path)) + .select(MediaFile::as_select()) + .first(conn) + .optional() +} + +fn catalog_file_needs_update( + existing: &CatalogMediaFile, + discovered: &DiscoveredMediaFile, + physical_path: &str, +) -> bool { + existing.path != physical_path + || existing.file_hash != discovered.file_hash + || existing.file_size != discovered.file_size + || existing.modified_at != discovered.modified_at + || existing.media_kind != discovered.media_kind +} + +fn media_file_row_needs_update( + existing: &MediaFile, + discovered: &DiscoveredMediaFile, + physical_path: &str, +) -> bool { + existing.path != physical_path + || existing.file_hash != discovered.file_hash + || existing.file_size != discovered.file_size + || existing.modified_at != discovered.modified_at + || existing.media_kind != discovered.media_kind +} + +fn clear_metadata_match_attempts_for_media_file( + conn: &mut SqliteConnection, + media_file_id: i32, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::media_file_id.eq(media_file_id)), + ) + .set(media_file_libraries_dsl::metadata_match_attempted_at.eq::>(None)) + .execute(conn)?; + + Ok(()) +} + +fn delete_unreferenced_media_files( + conn: &mut SqliteConnection +) -> Result { + diesel::sql_query( + "DELETE FROM media_files WHERE NOT EXISTS ( SELECT 1 FROM media_file_libraries WHERE \ + media_file_libraries.media_file_id = media_files.id )", + ) + .execute(conn) +} + +fn mark_library_root_files_missing( + conn: &mut SqliteConnection, + library_id: i32, + source_root_path: &str, + missing_since: i64, +) -> Result { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::library_id.eq(library_id)) + .filter(media_file_libraries_dsl::source_root_path.eq(source_root_path)) + .filter(media_file_libraries_dsl::deleted_at.is_null()) + .filter(media_file_libraries_dsl::missing_since.is_null()), + ) + .set(media_file_libraries_dsl::missing_since.eq(missing_since)) + .execute(conn) +} + +fn mark_unscanned_library_roots_missing( + conn: &mut SqliteConnection, + library_id: i32, + roots: &[String], + scanned_roots: &HashSet, + missing_since: i64, +) -> Result { + let mut marked = 0; + for root in roots { + if scanned_roots.contains(root) { + continue; + } + marked += mark_library_root_files_missing(conn, library_id, root, missing_since)?; + } + Ok(marked) +} + +fn stat_matches_catalog_file( + file: &CatalogMediaFile, + candidate: &FileHashCandidate<'_>, +) -> bool { + file.file_size == candidate.file_size && file.modified_at == candidate.modified_at +} + +fn stat_matches_media_file( + file: &MediaFile, + candidate: &FileHashCandidate<'_>, +) -> bool { + file.file_size == candidate.file_size && file.modified_at == candidate.modified_at +} + +fn reusable_scanner_hash(hash: &str) -> Option { + hash.strip_prefix("imohash:") + .filter(|value| { + value.len() == 32 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .map(|_| hash.to_string()) +} + +fn reusable_file_hash_for_scan( + conn: &mut SqliteConnection, + library_id: i32, + candidate: FileHashCandidate<'_>, +) -> Result, diesel::result::Error> { + if let Some(existing_file) = load_catalog_file_for_library_path( + conn, + library_id, + candidate.source_root_path, + candidate.relative_path, + )? { + if stat_matches_catalog_file(&existing_file, &candidate) { + if let Some(hash) = reusable_scanner_hash(&existing_file.file_hash) { + return Ok(Some(hash)); + } + } + } + + let physical_path = candidate.full_path.to_string_lossy().to_string(); + if let Some(existing_physical_file) = load_media_file_by_path(conn, &physical_path)? { + if stat_matches_media_file(&existing_physical_file, &candidate) { + if let Some(hash) = reusable_scanner_hash(&existing_physical_file.file_hash) { + return Ok(Some(hash)); + } + } + } + + Ok(None) +} + +fn sync_discovered_media_file( + conn: &mut SqliteConnection, + library_row: &MediaLibrary, + discovered_file: &DiscoveredMediaFile, + probe_context: ProbeContext<'_>, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + use crate::db::schema::media_files::dsl as media_files_dsl; + + let existing_file = load_catalog_file_for_library_path( + conn, + library_row.id, + &discovered_file.source_root_path, + &discovered_file.relative_path, + )?; + let physical_path = discovered_file.physical_path(); + let existing_physical_file = load_media_file_by_path(conn, &physical_path)?; + let metadata_source_hash = existing_physical_file + .as_ref() + .map(|file| file.file_hash.as_str()) + .or_else(|| existing_file.as_ref().map(|file| file.file_hash.as_str())); + let should_refresh_title = existing_file + .as_ref() + .map(|file| file.display_title.as_deref() != Some(discovered_file.default_title.as_str())) + .unwrap_or(true); + let should_refresh_metadata = metadata_source_hash + .map(|file_hash| file_hash != discovered_file.file_hash) + .unwrap_or(true); + let should_restore_file = existing_file + .as_ref() + .map(|file| file.missing_since.is_some() || file.deleted_at.is_some()) + .unwrap_or(false); + + let metadata = if should_refresh_metadata { + extract_metadata(discovered_file, probe_context) + } else { + existing_physical_file + .as_ref() + .map(extracted_metadata_from_file_row) + .or_else(|| existing_file.as_ref().map(extracted_metadata_from_existing)) + .unwrap_or_else(|| default_metadata(discovered_file)) + }; + let display_title = Some(discovered_file.default_title.clone()); + let metadata_match_attempted_at = if should_refresh_metadata || should_refresh_title { + None + } else { + existing_file + .as_ref() + .and_then(|file| file.metadata_match_attempted_at) + }; + let file_values = discovered_file.to_new_media_file(metadata); + let media_file_id = if let Some(existing_physical_file) = existing_physical_file.as_ref() { + let file_hash_changed = existing_physical_file.file_hash != discovered_file.file_hash; + if media_file_row_needs_update(existing_physical_file, discovered_file, &physical_path) { + diesel::update( + media_files_dsl::media_files + .filter(media_files_dsl::id.eq(existing_physical_file.id)), + ) + .set(&file_values) + .execute(conn)?; + if file_hash_changed { + clear_metadata_match_attempts_for_media_file(conn, existing_physical_file.id)?; + } + } + existing_physical_file.id + } else if let Some(existing_file) = existing_file.as_ref() { + let file_hash_changed = existing_file.file_hash != discovered_file.file_hash; + if catalog_file_needs_update(existing_file, discovered_file, &physical_path) { + diesel::update( + media_files_dsl::media_files.filter(media_files_dsl::id.eq(existing_file.id)), + ) + .set(&file_values) + .execute(conn)?; + if file_hash_changed { + clear_metadata_match_attempts_for_media_file(conn, existing_file.id)?; + } + } + existing_file.id + } else { + diesel::insert_into(media_files_dsl::media_files) + .values(&file_values) + .execute(conn)?; + media_files_dsl::media_files + .filter(media_files_dsl::path.eq(&physical_path)) + .select(media_files_dsl::id) + .first::(conn)? + }; + let membership_values = discovered_file.to_new_media_file_library( + media_file_id, + library_row.id, + display_title, + metadata_match_attempted_at, + ); + + if let Some(existing_file) = existing_file { + if existing_file.file_hash != discovered_file.file_hash + || should_refresh_title + || should_restore_file + || existing_file.id != media_file_id + { + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::id.eq(existing_file.library_file_id)), + ) + .set(&membership_values) + .execute(conn)?; + } + } else { + diesel::insert_into(media_file_libraries_dsl::media_file_libraries) + .values(&membership_values) + .execute(conn)?; + }; + + Ok(()) +} + +struct CatalogSyncScannerSink<'a, 'b, 'c> { + conn: &'a mut SqliteConnection, + library_row: &'b MediaLibrary, + probe_context: ProbeContext<'c>, + scan_started_at: i64, +} + +impl ScannerSink for CatalogSyncScannerSink<'_, '_, '_> { + type Error = diesel::result::Error; + + fn scanned_root( + &mut self, + source_root_path: &str, + ) -> Result<(), Self::Error> { + mark_library_root_files_missing( + self.conn, + self.library_row.id, + source_root_path, + self.scan_started_at, + )?; + Ok(()) + } + + fn file_hash( + &mut self, + candidate: FileHashCandidate<'_>, + ) -> Result, Self::Error> { + reusable_file_hash_for_scan(self.conn, self.library_row.id, candidate) + } + + fn file( + &mut self, + file: DiscoveredMediaFile, + ) -> Result<(), Self::Error> { + sync_discovered_media_file(self.conn, self.library_row, &file, self.probe_context) + } +} + +/// Sync configured libraries into the persistent catalog and refresh their inventory. +pub fn sync_library_catalog( + conn: &mut SqliteConnection, + libraries: &[MediaLibrarySettings], + ffmpeg_settings: &FfmpegSettings, +) -> Result, diesel::result::Error> { + sync_library_catalog_filtered(conn, libraries, ffmpeg_settings, None) +} + +fn sync_library_catalog_filtered( + conn: &mut SqliteConnection, + libraries: &[MediaLibrarySettings], + ffmpeg_settings: &FfmpegSettings, + target_library_id: Option, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + use crate::db::schema::scan_state::dsl as scan_state_dsl; + + let probe_context = ProbeContext { + ffprobe_path: &ffmpeg_settings.ffprobe_path, + enabled: detect_binary(&ffmpeg_settings.ffprobe_path).available, + }; + let existing_library_rows = media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.asc()) + .select(MediaLibrary::as_select()) + .load::(conn)?; + let mut persisted = Vec::with_capacity( + target_library_id + .map(|_| 1) + .unwrap_or_else(|| libraries.len()), + ); + + for (index, library) in libraries.iter().enumerate() { + let existing_library = existing_library_rows.get(index).cloned(); + if target_library_id + .zip(existing_library.as_ref().map(|library| library.id)) + .is_some_and(|(target_library_id, existing_id)| target_library_id != existing_id) + { + continue; + } + + let library_label = existing_library + .as_ref() + .map(|row| format!("{} ({})", row.id, row.name)) + .unwrap_or_else(|| format!("new library {}", index + 1)); + let mut library_values = media_library_record_values(library); + if library_values.name.trim().is_empty() { + library_values.name = Path::new(&library_values.path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Unnamed library".into()); + } + + let library_row = if let Some(existing_library) = existing_library { + diesel::update( + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(existing_library.id)), + ) + .set(&library_values) + .execute(conn)?; + + media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(existing_library.id)) + .select(MediaLibrary::as_select()) + .first(conn)? + } else { + diesel::insert_into(media_libraries_dsl::media_libraries) + .values(&library_values) + .execute(conn)?; + + media_libraries_dsl::media_libraries + .order(media_libraries_dsl::id.desc()) + .select(MediaLibrary::as_select()) + .first(conn)? + }; + + let existing_state = scan_state_dsl::scan_state + .filter(scan_state_dsl::library_id.eq(library_row.id)) + .select(ScanState::as_select()) + .first(conn) + .optional()?; + let next_scan_revision = existing_state + .as_ref() + .map(|state| state.scan_revision + 1) + .unwrap_or(1); + let scan_started_at = current_timestamp(); + let last_scanned_at = Some(scan_started_at); + + log::info!("Scanning media library catalog for {library_label} with streaming persistence"); + let inspection = { + let mut scanner_sink = CatalogSyncScannerSink { + conn, + library_row: &library_row, + probe_context, + scan_started_at, + }; + inspect_library_streaming(library, &mut scanner_sink)? + }; + log::info!( + "Finished streaming media library catalog for {}: {} file(s)", + library_label, + inspection.summary.total_files + ); + + let missing_unscanned_files = mark_unscanned_library_roots_missing( + conn, + library_row.id, + &inspection.summary.paths, + &inspection.scanned_root_paths, + scan_started_at, + )?; + if missing_unscanned_files > 0 { + log::warn!( + "Marked {} existing file row(s) as missing in library {} ({}) because their \ + source roots were not scanned successfully", + missing_unscanned_files, + library_row.id, + library_row.name + ); + } + sync_logical_media_items_for_library(conn, &library_row, &inspection.summary.kind)?; + + let state_values = NewScanState { + library_id: library_row.id, + last_status: inspection.summary.status.as_storage_value().to_string(), + last_error: inspection.summary.error.clone(), + scan_revision: next_scan_revision, + last_scanned_at, + total_files: inspection.summary.total_files as i64, + video_files: inspection.summary.video_files as i64, + audio_files: inspection.summary.audio_files as i64, + image_files: inspection.summary.image_files as i64, + book_files: inspection.summary.book_files as i64, + other_files: inspection.summary.other_files as i64, + }; + + if let Some(existing_state) = existing_state { + diesel::update( + scan_state_dsl::scan_state.filter(scan_state_dsl::id.eq(existing_state.id)), + ) + .set(&state_values) + .execute(conn)?; + } else { + diesel::insert_into(scan_state_dsl::scan_state) + .values(&state_values) + .execute(conn)?; + } + + persisted.push(PersistedLibrarySummary { + id: library_row.id, + name: inspection.summary.name, + path: inspection.summary.path, + paths: inspection.summary.paths, + recursive: inspection.summary.recursive, + kind: inspection.summary.kind, + scanner: inspection.summary.scanner, + metadata_providers: library.metadata_providers.clone(), + metadata_language_mode: library.metadata_language_mode.clone(), + metadata_languages: library.metadata_languages.clone(), + status: inspection.summary.status, + scan_revision: next_scan_revision, + last_scanned_at, + total_files: state_values.total_files, + video_files: state_values.video_files, + audio_files: state_values.audio_files, + image_files: state_values.image_files, + book_files: state_values.book_files, + other_files: state_values.other_files, + error: state_values.last_error, + metadata_refresh_total: 0, + metadata_refresh_pending: 0, + metadata_refresh_completed: 0, + metadata_refresh_failed: 0, + missing_files: 0, + missing_items: 0, + }); + } + + delete_unreferenced_media_files(conn)?; + + Ok(persisted) +} + +fn sync_logical_media_items_for_library( + conn: &mut SqliteConnection, + library: &MediaLibrary, + library_kind: &MediaLibraryKind, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + + let files = load_catalog_files_for_library(conn, library.id, false)?; + let existing_items = media_items_dsl::media_items + .filter(media_items_dsl::library_id.eq(library.id)) + .select(MediaItem::as_select()) + .load::(conn)?; + + let planned = plan_library_media_items(&files, library_kind, library.id); + let planned_keys = planned + .items + .iter() + .map(|item| item.identity_key.clone()) + .collect::>(); + let mut existing_by_key = existing_items + .iter() + .cloned() + .map(|item| (item.identity_key.clone(), item)) + .collect::>(); + let existing_by_id = existing_items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let mut item_ids_by_key = HashMap::new(); + + for plan in planned + .items + .iter() + .filter(|item| item.parent_identity_key.is_none()) + { + upsert_planned_media_item( + conn, + library.id, + plan, + None, + &mut existing_by_key, + &existing_by_id, + &mut item_ids_by_key, + )?; + } + + for plan in planned + .items + .iter() + .filter(|item| item.parent_identity_key.is_some()) + { + let parent_id = plan + .parent_identity_key + .as_ref() + .and_then(|identity_key| item_ids_by_key.get(identity_key)) + .copied(); + upsert_planned_media_item( + conn, + library.id, + plan, + parent_id, + &mut existing_by_key, + &existing_by_id, + &mut item_ids_by_key, + )?; + } + + for file in &files { + let Some(identity_key) = planned.leaf_identity_by_file_id.get(&file.library_file_id) else { + continue; + }; + let Some(item_id) = item_ids_by_key.get(identity_key).copied() else { + continue; + }; + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::id.eq(file.library_file_id)), + ) + .set(media_file_libraries_dsl::media_item_id.eq(item_id)) + .execute(conn)?; + } + + let placeholder_keep_ids = metadata_placeholder_keep_ids(&existing_items, &planned_keys); + let stale_ids = existing_items + .into_iter() + .filter(|item| !planned_keys.contains(&item.identity_key)) + .filter(|item| !placeholder_keep_ids.contains(&item.id)) + .filter(|item| item.deleted_at.is_none()) + .map(|item| item.id) + .collect::>(); + if !stale_ids.is_empty() { + mark_media_items_deleted(conn, &stale_ids, current_timestamp())?; + } + refresh_library_child_counts(conn, library.id)?; + + Ok(()) +} + +fn metadata_placeholder_keep_ids( + existing_items: &[MediaItem], + planned_keys: &HashSet, +) -> HashSet { + let planned_item_ids = existing_items + .iter() + .filter(|item| planned_keys.contains(&item.identity_key)) + .map(|item| item.id) + .collect::>(); + + let mut keep_ids = HashSet::new(); + loop { + let mut changed = false; + for item in existing_items { + if item.deleted_at.is_some() + || !is_metadata_placeholder_item(item) + || planned_keys.contains(&item.identity_key) + || keep_ids.contains(&item.id) + { + continue; + } + + let Some(parent_id) = item.parent_id else { + continue; + }; + if planned_item_ids.contains(&parent_id) || keep_ids.contains(&parent_id) { + changed |= keep_ids.insert(item.id); + } + } + + if !changed { + break; + } + } + + keep_ids +} + +fn is_metadata_placeholder_item(item: &MediaItem) -> bool { + item.missing_since.is_some() + && item.file_size.is_none() + && !item.playable + && matches!(item.item_type.as_str(), "season" | "episode") +} + +/// Mark missing media files and items as deleted from the active catalog. +pub fn delete_missing_media_items( + conn: &mut SqliteConnection, + library_id: Option, + missing_before_or_at: Option, +) -> Result { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + + let mut file_query = media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::deleted_at.is_null()) + .filter(media_file_libraries_dsl::missing_since.is_not_null()) + .into_boxed(); + let mut item_query = media_items_dsl::media_items + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_not_null()) + .into_boxed(); + + if let Some(library_id) = library_id { + file_query = file_query.filter(media_file_libraries_dsl::library_id.eq(library_id)); + item_query = item_query.filter(media_items_dsl::library_id.eq(library_id)); + } + if let Some(cutoff) = missing_before_or_at { + file_query = file_query.filter(media_file_libraries_dsl::missing_since.le(cutoff)); + item_query = item_query.filter(media_items_dsl::missing_since.le(cutoff)); + } + + let file_ids = file_query + .select(media_file_libraries_dsl::id) + .load::(conn)?; + let item_ids = item_query.select(media_items_dsl::id).load::(conn)?; + + let deleted_at = current_timestamp(); + let deleted_files = if file_ids.is_empty() { + 0 + } else { + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::id.eq_any(&file_ids)), + ) + .set(media_file_libraries_dsl::deleted_at.eq(deleted_at)) + .execute(conn)? as i64 + }; + + let removed_collection_items = if item_ids.is_empty() { + 0 + } else { + diesel::delete( + collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::media_item_id.eq_any(&item_ids)), + ) + .execute(conn)? as i64 + }; + + let deleted_items = if item_ids.is_empty() { + 0 + } else { + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq_any(&item_ids))) + .set(media_items_dsl::deleted_at.eq(deleted_at)) + .execute(conn)? as i64 + }; + + Ok(MissingItemsCleanupSummary { + library_id, + deleted_files, + deleted_items, + removed_collection_items, + }) +} + +fn mark_media_items_deleted( + conn: &mut SqliteConnection, + item_ids: &[i32], + deleted_at: i64, +) -> Result { + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + + if item_ids.is_empty() { + return Ok(0); + } + + diesel::delete( + collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::media_item_id.eq_any(item_ids)), + ) + .execute(conn)?; + + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq_any(item_ids))) + .set(media_items_dsl::deleted_at.eq(deleted_at)) + .execute(conn) +} + +/// Ensure provider-known missing seasons and episodes exist in the catalog for one show. +/// +/// All provider seasons are materialized as missing season rows when absent. Provider episodes are +/// materialized only for seasons that already have at least one playable local episode row. +pub fn upsert_show_metadata_descendant_items( + conn: &mut SqliteConnection, + show_item_id: i32, + plan: &ShowMetadataDescendantPlan, +) -> Result { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let Some(show) = load_media_item_row(conn, show_item_id)? else { + return Err(diesel::result::Error::NotFound); + }; + if show.item_type != "show" { + return Err(diesel::result::Error::NotFound); + } + + let mut season_plans = plan + .seasons + .iter() + .filter(|season| season.season_number > 0) + .map(|season| (season.season_number, season.display_title.clone())) + .collect::>(); + let mut episodes_by_season = HashMap::>::new(); + for episode in plan + .episodes + .iter() + .filter(|episode| episode.season_number > 0 && episode.episode_number > 0) + { + season_plans + .entry(episode.season_number) + .or_insert_with(|| None); + episodes_by_season + .entry(episode.season_number) + .or_default() + .push(episode.clone()); + } + + if season_plans.is_empty() { + return Ok(ShowMetadataDescendantItems::default()); + } + + let mut existing_seasons = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(show.id)) + .filter(media_items_dsl::item_type.eq("season")) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .load::(conn)? + .into_iter() + .filter_map(|season| season.season_number.map(|number| (number, season))) + .collect::>(); + + let now = current_timestamp(); + let show_relative_path = placeholder_parent_path(&show); + let mut season_numbers = season_plans.keys().copied().collect::>(); + season_numbers.sort_unstable(); + season_numbers.dedup(); + for season_number in season_numbers { + if existing_seasons.contains_key(&season_number) { + continue; + } + + let display_title = season_plans + .get(&season_number) + .and_then(|title| title.clone()) + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| format!("Season {}", season_number)); + let values = NewMediaItem { + library_id: show.library_id, + parent_id: Some(show.id), + identity_key: format!("{}:season:{}", show.identity_key, season_number), + item_type: "season".into(), + display_title, + relative_path: Some(format!("{show_relative_path}/Season {season_number}")), + media_kind: Some("video".into()), + season_number: Some(season_number), + episode_number: None, + child_count: 0, + playable: false, + file_size: None, + duration_ms: None, + modified_at: None, + created_at: Some(now), + updated_at: Some(now), + missing_since: Some(now), + deleted_at: None, + }; + let season = upsert_metadata_placeholder_item(conn, values)?; + existing_seasons.insert(season_number, season); + } + + let mut seasons_with_local_episodes = HashSet::new(); + for (season_number, season) in &existing_seasons { + let has_local_episode = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(season.id)) + .filter(media_items_dsl::item_type.eq("episode")) + .filter(media_items_dsl::playable.eq(true)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(media_items_dsl::id) + .first::(conn) + .optional()? + .is_some(); + if has_local_episode { + seasons_with_local_episodes.insert(*season_number); + } + } + + let mut episodes_by_number = HashMap::<(i32, i32), MediaItem>::new(); + let mut episode_numbers = episodes_by_season + .into_iter() + .filter(|(season_number, _)| seasons_with_local_episodes.contains(season_number)) + .flat_map(|(season_number, episodes)| { + episodes + .into_iter() + .map(move |episode| ((season_number, episode.episode_number), episode)) + }) + .collect::>(); + episode_numbers.sort_by_key(|entry| entry.0); + episode_numbers.dedup_by(|left, right| left.0 == right.0); + + let mut materialized_season_numbers = episode_numbers + .iter() + .map(|((season_number, _), _)| *season_number) + .collect::>(); + materialized_season_numbers.sort_unstable(); + materialized_season_numbers.dedup(); + for season_number in materialized_season_numbers { + let Some(season) = existing_seasons.get(&season_number) else { + continue; + }; + for episode in media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(season.id)) + .filter(media_items_dsl::item_type.eq("episode")) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .load::(conn)? + { + if let Some(episode_number) = episode.episode_number { + episodes_by_number.insert((season_number, episode_number), episode); + } + } + } + + for ((season_number, episode_number), episode_plan) in episode_numbers { + if episodes_by_number.contains_key(&(season_number, episode_number)) { + continue; + } + let Some(season) = existing_seasons.get(&season_number) else { + continue; + }; + + let display_title = episode_plan + .display_title + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| format!("Episode {}", episode_number)); + let season_relative_path = placeholder_parent_path(season); + let values = NewMediaItem { + library_id: show.library_id, + parent_id: Some(season.id), + identity_key: format!( + "{}:metadata-episode:{}", + season.identity_key, episode_number + ), + item_type: "episode".into(), + display_title, + relative_path: Some(format!("{season_relative_path}/Episode {episode_number}")), + media_kind: Some("video".into()), + season_number: Some(season_number), + episode_number: Some(episode_number), + child_count: 0, + playable: false, + file_size: None, + duration_ms: None, + modified_at: None, + created_at: Some(now), + updated_at: Some(now), + missing_since: Some(now), + deleted_at: None, + }; + let episode = upsert_metadata_placeholder_item(conn, values)?; + episodes_by_number.insert((season_number, episode_number), episode); + } + + let mut count_refresh_ids = existing_seasons + .values() + .map(|season| season.id) + .collect::>(); + count_refresh_ids.push(show.id); + refresh_media_item_child_counts(conn, &count_refresh_ids)?; + + let mut result = ShowMetadataDescendantItems { + seasons_with_local_episodes, + ..ShowMetadataDescendantItems::default() + }; + for (season_number, season) in existing_seasons { + if let Some(season) = load_media_item_row(conn, season.id)? { + result + .seasons_by_number + .insert(season_number, to_media_item_summary(season)); + } + } + for ((season_number, episode_number), episode) in episodes_by_number { + if let Some(episode) = load_media_item_row(conn, episode.id)? { + result.episodes_by_number.insert( + (season_number, episode_number), + to_media_item_summary(episode), + ); + } + } + + Ok(result) +} + +fn placeholder_parent_path(item: &MediaItem) -> String { + item.relative_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| item.display_title.clone()) +} + +fn upsert_metadata_placeholder_item( + conn: &mut SqliteConnection, + values: NewMediaItem, +) -> Result { + use crate::db::schema::media_items::dsl as media_items_dsl; + + if let Some(existing) = media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(&values.identity_key)) + .select(MediaItem::as_select()) + .first::(conn) + .optional()? + { + let mut values = values; + values.created_at = existing.created_at.or(values.created_at); + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq(existing.id))) + .set(&values) + .execute(conn)?; + return media_items_dsl::media_items + .filter(media_items_dsl::id.eq(existing.id)) + .select(MediaItem::as_select()) + .first(conn); + } + + diesel::insert_into(media_items_dsl::media_items) + .values(&values) + .execute(conn)?; + media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(values.identity_key)) + .select(MediaItem::as_select()) + .first(conn) +} + +fn refresh_library_child_counts( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let item_ids = media_items_dsl::media_items + .filter(media_items_dsl::library_id.eq(library_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(media_items_dsl::id) + .load::(conn)?; + refresh_media_item_child_counts(conn, &item_ids) +} + +fn refresh_media_item_child_counts( + conn: &mut SqliteConnection, + item_ids: &[i32], +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut item_ids = item_ids.to_vec(); + item_ids.sort_unstable(); + item_ids.dedup(); + for item_id in item_ids { + let child_count = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .count() + .get_result::(conn)?; + let child_count = i32::try_from(child_count).unwrap_or(i32::MAX); + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq(item_id))) + .set(media_items_dsl::child_count.eq(child_count)) + .execute(conn)?; + } + + Ok(()) +} + +fn upsert_planned_media_item( + conn: &mut SqliteConnection, + library_id: i32, + plan: &PlannedMediaItem, + parent_id: Option, + existing_by_key: &mut HashMap, + existing_by_id: &HashMap, + item_ids_by_key: &mut HashMap, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let values = NewMediaItem { + library_id, + parent_id, + identity_key: plan.identity_key.clone(), + item_type: plan.item_type.clone(), + display_title: plan.display_title.clone(), + relative_path: plan.relative_path.clone(), + media_kind: plan.media_kind.clone(), + season_number: plan.season_number, + episode_number: plan.episode_number, + child_count: plan.child_count, + playable: plan.playable, + file_size: plan.file_size, + duration_ms: plan.duration_ms, + modified_at: plan.modified_at, + created_at: plan.modified_at.or_else(|| Some(current_timestamp())), + updated_at: Some(current_timestamp()), + missing_since: plan.missing_since, + deleted_at: None, + }; + + let target = existing_by_key + .remove(&plan.identity_key) + .or_else(|| { + plan.explicit_id + .and_then(|id| existing_by_id.get(&id)) + .filter(|existing| existing_item_matches_plan(existing, parent_id, plan)) + .cloned() + }) + .or_else(|| take_matching_metadata_placeholder(existing_by_key, parent_id, plan)); + + let item_id = if let Some(existing) = target { + diesel::update(media_items_dsl::media_items.filter(media_items_dsl::id.eq(existing.id))) + .set(&values) + .execute(conn)?; + existing.id + } else if let Some(explicit_id) = plan.explicit_id { + let explicit_id_available = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(explicit_id)) + .select(media_items_dsl::id) + .first::(conn) + .optional()? + .is_none(); + + if explicit_id_available { + diesel::insert_into(media_items_dsl::media_items) + .values((media_items_dsl::id.eq(explicit_id), &values)) + .execute(conn)?; + explicit_id + } else { + diesel::insert_into(media_items_dsl::media_items) + .values(&values) + .execute(conn)?; + media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(&plan.identity_key)) + .select(media_items_dsl::id) + .first::(conn)? + } + } else { + diesel::insert_into(media_items_dsl::media_items) + .values(&values) + .execute(conn)?; + media_items_dsl::media_items + .filter(media_items_dsl::identity_key.eq(&plan.identity_key)) + .select(media_items_dsl::id) + .first::(conn)? + }; + + item_ids_by_key.insert(plan.identity_key.clone(), item_id); + Ok(()) +} + +fn existing_item_matches_plan( + existing: &MediaItem, + parent_id: Option, + plan: &PlannedMediaItem, +) -> bool { + existing.item_type == plan.item_type + && existing.parent_id == parent_id + && existing.season_number == plan.season_number + && existing.episode_number == plan.episode_number +} + +fn take_matching_metadata_placeholder( + existing_by_key: &mut HashMap, + parent_id: Option, + plan: &PlannedMediaItem, +) -> Option { + if !matches!(plan.item_type.as_str(), "season" | "episode") { + return None; + } + + let matched_key = existing_by_key + .iter() + .find(|(_, item)| { + item.deleted_at.is_none() + && is_metadata_placeholder_item(item) + && item.parent_id == parent_id + && item.item_type == plan.item_type + && item.season_number == plan.season_number + && (plan.item_type != "episode" || item.episode_number == plan.episode_number) + }) + .map(|(identity_key, _)| identity_key.clone())?; + + existing_by_key.remove(&matched_key) +} + +fn plan_library_media_items( + files: &[CatalogMediaFile], + library_kind: &MediaLibraryKind, + library_id: i32, +) -> PlannedLibraryItems { + let mut items_by_key = HashMap::::new(); + let mut leaf_identity_by_file_id = HashMap::new(); + + for file in files { + let available_leaf_count = if file.missing_since.is_none() { 1 } else { 0 }; + let missing_leaf_count = if file.missing_since.is_some() { 1 } else { 0 }; + if *library_kind == MediaLibraryKind::Shows && file.media_kind == "video" { + let fallback_title = fallback_title_from_relative_path(&file.relative_path); + let parsed = parse_show_path( + &file.relative_path, + file.display_title + .as_deref() + .unwrap_or(fallback_title.as_str()), + library_id, + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.show_key.clone(), + parent_identity_key: None, + item_type: "show".into(), + display_title: parsed.show_title.clone(), + relative_path: parent_relative_path(&file.relative_path, 1), + media_kind: Some("video".into()), + season_number: None, + episode_number: None, + playable: false, + child_count: 0, + file_size: None, + duration_ms: None, + modified_at: file.modified_at, + explicit_id: None, + missing_since: file.missing_since, + available_leaf_count, + missing_leaf_count, + }, + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.season_key.clone(), + parent_identity_key: Some(parsed.show_key.clone()), + item_type: "season".into(), + display_title: parsed.season_title.clone(), + relative_path: parent_relative_path(&file.relative_path, 2), + media_kind: Some("video".into()), + season_number: parsed.season_number, + episode_number: None, + playable: false, + child_count: 0, + file_size: None, + duration_ms: None, + modified_at: file.modified_at, + explicit_id: None, + missing_since: file.missing_since, + available_leaf_count, + missing_leaf_count, + }, + ); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: parsed.episode_key.clone(), + parent_identity_key: Some(parsed.season_key.clone()), + item_type: "episode".into(), + display_title: parsed.episode_title.clone(), + relative_path: Some(file.relative_path.clone()), + media_kind: Some(file.media_kind.clone()), + season_number: parsed.season_number, + episode_number: parsed.episode_number, + playable: true, + child_count: 0, + file_size: Some(file.file_size), + duration_ms: file.duration_ms, + modified_at: file.modified_at, + explicit_id: Some(file.library_file_id), + missing_since: file.missing_since, + available_leaf_count, + missing_leaf_count, + }, + ); + leaf_identity_by_file_id.insert(file.library_file_id, parsed.episode_key); + continue; + } + + let item_type = match file.media_kind.as_str() { + "audio" => "track", + "image" => "photo", + "book" => "book", + _ => "movie", + }; + let identity_key = format!("file:{}", file.library_file_id); + upsert_planned_item( + &mut items_by_key, + PlannedMediaItem { + identity_key: identity_key.clone(), + parent_identity_key: None, + item_type: item_type.into(), + display_title: file + .display_title + .clone() + .unwrap_or_else(|| fallback_title_from_relative_path(&file.relative_path)), + relative_path: Some(file.relative_path.clone()), + media_kind: Some(file.media_kind.clone()), + season_number: None, + episode_number: None, + playable: matches!(file.media_kind.as_str(), "video" | "audio"), + child_count: 0, + file_size: Some(file.file_size), + duration_ms: file.duration_ms, + modified_at: file.modified_at, + explicit_id: Some(file.library_file_id), + missing_since: file.missing_since, + available_leaf_count, + missing_leaf_count, + }, + ); + leaf_identity_by_file_id.insert(file.library_file_id, identity_key); + } + + let mut child_counts = HashMap::::new(); + for item in items_by_key.values() { + if let Some(parent_identity_key) = &item.parent_identity_key { + *child_counts.entry(parent_identity_key.clone()).or_default() += 1; + } + } + for item in items_by_key.values_mut() { + item.child_count = child_counts + .get(&item.identity_key) + .copied() + .unwrap_or_default(); + } + + let depth_by_key = items_by_key + .keys() + .map(|identity_key| { + ( + identity_key.clone(), + item_depth(identity_key, &items_by_key), + ) + }) + .collect::>(); + let mut items = items_by_key.into_values().collect::>(); + items.sort_by(|left, right| { + depth_by_key + .get(&left.identity_key) + .copied() + .unwrap_or_default() + .cmp( + &depth_by_key + .get(&right.identity_key) + .copied() + .unwrap_or_default(), + ) + .then_with(|| left.season_number.cmp(&right.season_number)) + .then_with(|| left.episode_number.cmp(&right.episode_number)) + .then_with(|| left.display_title.cmp(&right.display_title)) + }); + + PlannedLibraryItems { + items, + leaf_identity_by_file_id, + } +} + +fn upsert_planned_item( + items_by_key: &mut HashMap, + item: PlannedMediaItem, +) { + items_by_key + .entry(item.identity_key.clone()) + .and_modify(|existing| { + existing.modified_at = existing.modified_at.max(item.modified_at); + existing.available_leaf_count += item.available_leaf_count; + existing.missing_leaf_count += item.missing_leaf_count; + existing.missing_since = if existing.available_leaf_count > 0 { + None + } else { + match (existing.missing_since, item.missing_since) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } + }; + existing.duration_ms = Some( + existing.duration_ms.unwrap_or_default() + item.duration_ms.unwrap_or_default(), + ) + .filter(|value| *value > 0); + existing.file_size = match (existing.file_size, item.file_size) { + (Some(left), Some(right)) => Some(left + right), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + }; + if existing.display_title.trim().is_empty() { + existing.display_title = item.display_title.clone(); + } + }) + .or_insert(item); +} + +fn item_depth( + identity_key: &str, + items_by_key: &HashMap, +) -> usize { + let mut depth = 0; + let mut next_parent = items_by_key + .get(identity_key) + .and_then(|item| item.parent_identity_key.as_deref()); + + while let Some(parent_identity_key) = next_parent { + depth += 1; + next_parent = items_by_key + .get(parent_identity_key) + .and_then(|item| item.parent_identity_key.as_deref()); + } + + depth +} + +fn parent_relative_path( + relative_path: &str, + depth: usize, +) -> Option { + let parts = relative_path + .replace('\\', "/") + .split('/') + .filter(|part| !part.trim().is_empty()) + .take(depth) + .map(str::to_string) + .collect::>(); + (!parts.is_empty()).then(|| parts.join("/")) +} + +/// Return persisted media files for a synchronized library. +pub fn get_library_files( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result, diesel::result::Error> { + let rows = load_catalog_files_for_library(conn, library_id, false)?; + + Ok(rows.into_iter().map(to_persisted_file_summary).collect()) +} + +/// Return whether a media library exists in the persistent catalog. +pub fn library_exists( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + Ok(media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first(conn) + .optional()? + .is_some()) +} + +/// List browser-facing media items, optionally filtered to one library. +pub fn list_media_items( + conn: &mut SqliteConnection, + library_id: Option, +) -> Result, diesel::result::Error> { + list_media_items_with_preferred_languages(conn, library_id, &[]) +} + +/// List browser-facing media items using the caller's preferred metadata languages. +pub fn list_media_items_with_preferred_languages( + conn: &mut SqliteConnection, + library_id: Option, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut query = media_items_dsl::media_items.into_boxed(); + query = query.filter(media_items_dsl::deleted_at.is_null()); + if let Some(library_id) = library_id { + query = query.filter(media_items_dsl::library_id.eq(library_id)); + } + + let rows = query + .order(( + media_items_dsl::display_title.asc(), + media_items_dsl::relative_path.asc(), + )) + .select(MediaItem::as_select()) + .load::(conn)?; + + let metadata_links = preferred_metadata_links_by_item_id( + conn, + &rows.iter().map(|row| row.id).collect::>(), + preferred_languages, + )?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let mut summary = to_media_item_summary(row); + let summary_id = summary.id; + apply_primary_metadata_link(&mut summary, metadata_links.get(&summary_id)); + items.push(summary); + } + apply_available_season_counts_from_summaries(&mut items); + + Ok(items) +} + +/// List browser-facing media items with current-user playback state applied. +pub fn list_media_items_for_user_with_preferred_languages( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + let mut items = + list_media_items_with_preferred_languages(conn, library_id, preferred_languages)?; + apply_user_playback_progress_to_summaries(conn, user_id, &mut items)?; + Ok(items) +} + +/// Return unmatched movie-like items that are eligible for automatic metadata linking. +pub fn list_automatic_metadata_candidates( + conn: &mut SqliteConnection, + library_id: Option, + limit: usize, +) -> Result, diesel::result::Error> { + list_automatic_metadata_candidates_with_options(conn, library_id, limit, false) +} + +/// Return unmatched movie-like items for a user-triggered metadata refresh. +/// +/// Manual library refreshes should retry currently unlinked movies even if a +/// previous automatic pass marked them as attempted. The normal candidate list +/// remains conservative so automatic polling does not repeatedly hit providers. +pub fn list_automatic_metadata_refresh_candidates( + conn: &mut SqliteConnection, + library_id: Option, + limit: usize, +) -> Result, diesel::result::Error> { + list_automatic_metadata_candidates_with_options(conn, library_id, limit, true) +} + +fn list_automatic_metadata_candidates_with_options( + conn: &mut SqliteConnection, + library_id: Option, + limit: usize, + include_previously_attempted: bool, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let libraries = media_libraries_dsl::media_libraries + .select(MediaLibrary::as_select()) + .load::(conn)?; + let libraries_by_id = libraries + .into_iter() + .map(|library| (library.id, library)) + .collect::>(); + let linked_item_ids = item_metadata_links_dsl::item_metadata_links + .filter(item_metadata_links_dsl::relation_kind.eq("primary")) + .select(item_metadata_links_dsl::media_item_id) + .load::(conn)? + .into_iter() + .collect::>(); + + let rows = load_active_catalog_files(conn)?; + + let mut candidates = Vec::new(); + for row in rows { + let Some(media_item_id) = row.media_item_id else { + continue; + }; + + if linked_item_ids.contains(&media_item_id) + || (!include_previously_attempted && row.metadata_match_attempted_at.is_some()) + { + continue; + } + if row.media_kind != "video" { + continue; + } + + let Some(library) = libraries_by_id.get(&row.library_id) else { + continue; + }; + if library_id.is_some_and(|requested_library_id| requested_library_id != library.id) { + continue; + } + let library_settings = media_library_settings_from_row(library.clone()); + if library_settings.kind != MediaLibraryKind::Movies { + continue; + } + if library_settings.metadata_providers.is_empty() { + continue; + } + + candidates.push(AutomaticMetadataCandidate { + item_id: media_item_id, + relative_path: row.relative_path.clone(), + display_title: row + .display_title + .unwrap_or_else(|| fallback_title_from_relative_path(&row.relative_path)), + modified_at: row.modified_at, + library_kind: library_settings.kind, + metadata_providers: library_settings.metadata_providers, + }); + } + + let show_rows = media_items_dsl::media_items + .filter(media_items_dsl::item_type.eq("show")) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .select(MediaItem::as_select()) + .load::(conn)?; + for row in show_rows { + if linked_item_ids.contains(&row.id) { + continue; + } + + let Some(library) = libraries_by_id.get(&row.library_id) else { + continue; + }; + if library_id.is_some_and(|requested_library_id| requested_library_id != library.id) { + continue; + } + let library_settings = media_library_settings_from_row(library.clone()); + if library_settings.kind != MediaLibraryKind::Shows { + continue; + } + if library_settings.metadata_providers.is_empty() { + continue; + } + + candidates.push(AutomaticMetadataCandidate { + item_id: row.id, + relative_path: row.relative_path.unwrap_or_default(), + display_title: row.display_title, + modified_at: row.modified_at, + library_kind: library_settings.kind, + metadata_providers: library_settings.metadata_providers, + }); + } + + candidates.sort_by(|left, right| { + right + .modified_at + .cmp(&left.modified_at) + .then_with(|| left.display_title.cmp(&right.display_title)) + }); + candidates.truncate(limit); + + Ok(candidates) +} + +/// Mark a media item as having been considered by the automatic metadata linker. +pub fn mark_metadata_match_attempted( + conn: &mut SqliteConnection, + item_id: i32, + attempted_at: i64, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_file_libraries::dsl as media_file_libraries_dsl; + + diesel::update( + media_file_libraries_dsl::media_file_libraries + .filter(media_file_libraries_dsl::media_item_id.eq(item_id)), + ) + .set(media_file_libraries_dsl::metadata_match_attempted_at.eq(attempted_at)) + .execute(conn)?; + Ok(()) +} + +/// Return a single browser-facing media item by its stable identifier. +pub fn get_media_item( + conn: &mut SqliteConnection, + item_id: i32, + data_dir: &str, +) -> Result, diesel::result::Error> { + get_media_item_with_preferred_languages(conn, item_id, data_dir, &[]) +} + +/// Return a single browser-facing media item by its stable identifier and preferred metadata +/// languages. +pub fn get_media_item_with_preferred_languages( + conn: &mut SqliteConnection, + item_id: i32, + data_dir: &str, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let item = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + + let Some(item) = item else { + return Ok(None); + }; + + let backing_file = load_backing_media_file(conn, item_id)?; + let mut detail = to_media_item_detail(item.clone(), backing_file.as_ref()); + if detail.item_type == "show" { + detail.available_season_count = Some(available_season_count_for_show(conn, detail.id)?); + } + detail.hierarchy = load_media_item_hierarchy(conn, &item, preferred_languages)?; + detail.children = + list_media_item_children_with_preferred_languages(conn, item.id, preferred_languages)?; + + let metadata_links = + prioritized_metadata_links_for_item(conn, item_id, item.library_id, preferred_languages)?; + if !metadata_links.is_empty() { + let primary_link = metadata_links + .iter() + .find(|link| link.relation_kind == "primary"); + if let Some(title) = primary_link + .and_then(|link| link.title.as_deref()) + .map(str::trim) + .filter(|title| !title.is_empty()) + { + detail.display_title = title.to_string(); + } + let presentation = presentation_from_metadata_links(conn, &metadata_links)?; + detail.tagline = presentation.tagline; + detail.overview = presentation.overview; + detail.genres = presentation.genres; + detail.release_year = presentation.release_year; + if presentation.logo_url.is_some() { + detail.logo_url = Some(format!("/api/v1/items/{}/artwork?kind=logo", item_id)); + } + detail.rating = presentation.rating; + detail.content_rating = presentation.content_rating; + detail.linked_media_type = presentation.media_type; + detail.has_metadata = true; + if let Some(link) = primary_link { + detail.metadata_refresh_state = Some(link.refresh_state.clone()); + detail.metadata_refresh_error = link.refresh_error.clone(); + detail.artwork_updated_at = link.updated_at; + } + detail.trailer_title = presentation.trailer_title; + detail.trailer_url = presentation.trailer_url; + detail.theme_song_url = presentation.theme_song_url; + detail.extras = metadata_extras_from_metadata_links(conn, &metadata_links)? + .into_iter() + .map(MediaItemExtra::from) + .collect(); + if presentation.poster_available { + detail.poster_url = Some(format!("/api/v1/items/{}/artwork?kind=poster", item_id)); + } + if presentation.backdrop_available { + detail.backdrop_url = Some(format!("/api/v1/items/{}/artwork?kind=backdrop", item_id)); + } + } + + if let Some(source_path) = resolve_media_item_source_path(conn, item_id)? { + let assets = discover_item_assets(item_id, &source_path, data_dir); + if assets.poster_path.is_some() { + detail.poster_url = Some(format!("/api/v1/items/{}/artwork?kind=poster", item_id)); + } + if assets.backdrop_path.is_some() { + detail.backdrop_url = Some(format!("/api/v1/items/{}/artwork?kind=backdrop", item_id)); + } + if assets.theme_song_path.is_some() { + detail.theme_song_url = Some(format!("/api/v1/items/{}/theme", item_id)); + } + detail.subtitle_tracks = assets + .subtitle_paths + .iter() + .enumerate() + .map(|(index, subtitle_path)| MediaSubtitleTrack { + index, + label: subtitle_label_from_path(&source_path, subtitle_path), + format: subtitle_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| "Subtitle".into()), + url: format!("/api/v1/items/{}/subtitles/{}", item_id, index), + }) + .collect(); + } + if detail.theme_song_url.is_none() { + detail.theme_song_url = + inherited_theme_song_url_for_item(conn, &item, data_dir, preferred_languages)?; + } + detail.audio_tracks = backing_file + .as_ref() + .and_then(|file| audio_tracks_from_metadata_json(file.metadata_json.as_deref())) + .unwrap_or_default(); + + Ok(Some(detail)) +} + +fn inherited_theme_song_url_for_item( + conn: &mut SqliteConnection, + item: &MediaItem, + data_dir: &str, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + let mut current_id = item.parent_id; + let mut visited = HashSet::new(); + + while let Some(item_id) = current_id { + if !visited.insert(item_id) { + break; + } + + let Some(parent) = load_media_item_row(conn, item_id)? else { + break; + }; + + if let Some(source_path) = resolve_media_item_source_path(conn, parent.id)? { + let assets = discover_item_assets(parent.id, &source_path, data_dir); + if assets.theme_song_path.is_some() { + return Ok(Some(format!("/api/v1/items/{}/theme", parent.id))); + } + } + + let metadata_links = prioritized_metadata_links_for_item( + conn, + parent.id, + parent.library_id, + preferred_languages, + )?; + if !metadata_links.is_empty() { + if let Some(theme_song_url) = + presentation_from_metadata_links(conn, &metadata_links)?.theme_song_url + { + return Ok(Some(theme_song_url)); + } + } + + current_id = parent.parent_id; + } + + Ok(None) +} + +/// Search browser-facing media items by title or relative path. +pub fn search_media_items( + conn: &mut SqliteConnection, + query: &str, + library_id: Option, +) -> Result, diesel::result::Error> { + search_media_items_with_preferred_languages(conn, query, library_id, &[]) +} + +/// Search browser-facing media items using the caller's preferred metadata languages. +pub fn search_media_items_with_preferred_languages( + conn: &mut SqliteConnection, + query: &str, + library_id: Option, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + + let query = query.trim().to_ascii_lowercase(); + let items = list_media_items_with_preferred_languages(conn, library_id, preferred_languages)?; + + Ok(items + .into_iter() + .filter(|item| { + item.display_title.to_ascii_lowercase().contains(&query) + || item.relative_path.to_ascii_lowercase().contains(&query) + || item.media_kind.to_ascii_lowercase().contains(&query) + }) + .collect()) +} + +/// Search browser-facing media items with current-user playback state applied. +pub fn search_media_items_for_user_with_preferred_languages( + conn: &mut SqliteConnection, + user_id: Option, + query: &str, + library_id: Option, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + let mut items = + search_media_items_with_preferred_languages(conn, query, library_id, preferred_languages)?; + apply_user_playback_progress_to_summaries(conn, user_id, &mut items)?; + Ok(items) +} + +/// Return Kodi/Plex-style media shelves for the browser home screen. +pub fn get_media_home( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, +) -> Result { + get_media_home_with_preferred_languages(conn, user_id, library_id, &[]) +} + +/// Return Kodi/Plex-style media shelves using the caller's preferred metadata languages. +pub fn get_media_home_with_preferred_languages( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, + preferred_languages: &[String], +) -> Result { + let items = list_media_items_for_user_with_preferred_languages( + conn, + user_id, + library_id, + preferred_languages, + )?; + + let continue_watching = get_continue_watching_items(conn, user_id, library_id, &items)?; + let recently_added = sort_recently_added(&items); + let recommended = sort_recommended(&items, &continue_watching); + let collection_provider_order = match library_id { + Some(library_id) => media_library_metadata_provider_order(conn, library_id)?, + None => Vec::new(), + }; + let collections = list_metadata_collection_summaries_with_preferred_languages( + conn, + library_id, + preferred_languages, + &collection_provider_order, + )?; + + Ok(MediaHome { + library_id, + shelves: vec![ + MediaShelf { + id: "continue_watching".into(), + title: "Continue watching".into(), + items: continue_watching, + }, + MediaShelf { + id: "recently_added".into(), + title: "Recently added".into(), + items: recently_added, + }, + MediaShelf { + id: "recommended".into(), + title: "Recommended".into(), + items: recommended, + }, + ], + collections, + }) +} + +/// Return a browser playback decision for a media item. +pub fn get_playback_decision( + conn: &mut SqliteConnection, + item_id: i32, + profile: Option<&ClientProfile>, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let item = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + + let Some(item) = item else { + return Ok(None); + }; + + let backing_file = load_backing_media_file(conn, item_id)?; + + Ok(Some(if let Some(row) = backing_file { + if row.missing_since.is_some() { + return Ok(Some(PlaybackDecision { + item_id, + can_direct_play: false, + transcode_required: false, + reason: "This item is missing from disk and cannot be played.".into(), + stream_url: None, + mime_type: detect_mime_type(&row), + transcode_container: None, + transcode_video_codec: None, + transcode_audio_codec: None, + video_transcode_required: false, + audio_transcode_required: false, + source_video_codec: row.video_codec, + source_audio_codec: row.audio_codec, + source_container: row.container, + })); + } + + let default_profile = ClientProfile { + client_type: "web".into(), + client_name: "Web".into(), + supported_containers: vec!["mp4".into(), "webm".into()], + supported_video_codecs: vec![ + "h264".into(), + "av1".into(), + "vp8".into(), + "vp9".into(), + ], + supported_audio_codecs: vec![ + "aac".into(), + "mp3".into(), + "opus".into(), + "vorbis".into(), + "flac".into(), + ], + supported_subtitle_formats: vec!["vtt".into()], + max_video_width: 0, + max_video_height: 0, + max_bitrate_kbps: 0, + supports_adaptive_streaming: false, + prefer_hls: false, + }; + let p = profile.unwrap_or(&default_profile); + + let can_direct_play = item.playable && can_client_direct_play(&row, p); + let mime_type = detect_mime_type(&row); + + let video_codec = row.video_codec.as_deref().unwrap_or(""); + let audio_codec = row.audio_codec.as_deref().unwrap_or(""); + let video_transcode_required = !video_codec.is_empty() + && !p + .supported_video_codecs + .iter() + .any(|c| codec_matches(video_codec, c)); + let audio_transcode_required = !audio_codec.is_empty() + && !p + .supported_audio_codecs + .iter() + .any(|c| codec_matches(audio_codec, c)); + + // Target codecs when transcoding is required + let transcode_video_codec = + if video_transcode_required { Some("libx264".into()) } else { None }; + let transcode_audio_codec = + if audio_transcode_required { Some("aac".into()) } else { None }; + let transcode_container = if !can_direct_play { Some("mp4".into()) } else { None }; + + PlaybackDecision { + item_id, + can_direct_play, + transcode_required: item.playable && !can_direct_play, + reason: if can_direct_play { + "Client direct play is supported for this item.".into() + } else { + "A transcode path will be required for playback.".into() + }, + stream_url: can_direct_play.then(|| format!("/api/v1/items/{}/stream", item_id)), + mime_type, + transcode_container, + transcode_video_codec, + transcode_audio_codec, + video_transcode_required, + audio_transcode_required, + source_video_codec: row.video_codec, + source_audio_codec: row.audio_codec, + source_container: row.container, + } + } else { + PlaybackDecision { + item_id, + can_direct_play: false, + transcode_required: false, + reason: "This item is a container and cannot be played directly.".into(), + stream_url: None, + mime_type: None, + transcode_container: None, + transcode_video_codec: None, + transcode_audio_codec: None, + video_transcode_required: false, + audio_transcode_required: false, + source_video_codec: None, + source_audio_codec: None, + source_container: None, + } + })) +} + +/// Resolve the direct-play source path for a media item. +pub fn resolve_media_item_source_path( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let item = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .first(conn) + .optional()?; + let media_file = load_backing_media_file(conn, item_id)?; + let Some(media_file) = media_file else { + if let Some(item) = item.filter(|item| item.playable) { + log::warn!( + "Playable media item {} ({}) is missing a backing media_files link", + item.id, + item.relative_path.as_deref().unwrap_or_default() + ); + } + return Ok(None); + }; + if media_file.missing_since.is_some() { + return Ok(None); + } + if let Some(item_relative_path) = item + .as_ref() + .and_then(|item| item.relative_path.as_deref()) + .filter(|value| !value.trim().is_empty()) + { + if item_relative_path != media_file.relative_path { + log::warn!( + "Ignoring mismatched backing media file for item {}: item path {:?}, media file \ + path {:?}", + item_id, + item_relative_path, + media_file.relative_path + ); + return Ok(None); + } + } + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(media_file.library_id)) + .select(MediaLibrary::as_select()) + .first(conn) + .optional()?; + + Ok(library.map(|library| { + if !media_file.source_root_path.trim().is_empty() { + PathBuf::from(media_file.source_root_path).join(&media_file.relative_path) + } else { + let fallback_root = media_library_settings_from_row(library) + .configured_paths() + .into_iter() + .next() + .unwrap_or_default(); + PathBuf::from(fallback_root).join(&media_file.relative_path) + } + })) +} + +/// Resolve a local theme-song asset path for a media item. +pub fn resolve_item_theme_song_path( + conn: &mut SqliteConnection, + item_id: i32, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + Ok(discover_item_assets(item_id, &source_path, data_dir).theme_song_path) +} + +/// Return ordered YouTube theme-song lookup candidates for a secondary metadata provider. +pub fn get_item_youtube_theme_provider_references( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, +) -> Result, diesel::result::Error> { + get_item_secondary_provider_references(conn, item_id, provider_id) +} + +/// Return ordered lookup candidates for a secondary metadata provider. +pub fn get_item_secondary_provider_references( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, +) -> Result, diesel::result::Error> { + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&provider_id) else { + return Ok(Vec::new()); + }; + let source_provider_ids = provider.descriptor().extends_provider_ids; + if source_provider_ids.is_empty() { + return Ok(Vec::new()); + } + + get_item_theme_song_source_references(conn, item_id, &source_provider_ids, provider) +} + +/// Return ordered YouTube trailer lookup candidates for a secondary metadata provider. +pub fn get_item_youtube_trailer_provider_references( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, +) -> Result, diesel::result::Error> { + get_item_secondary_provider_references(conn, item_id, provider_id) +} + +/// Return ordered collection lookup candidates for a secondary theme-song provider. +pub fn get_item_youtube_theme_collection_references( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + use crate::db::schema::metadata_collections::dsl as collections_dsl; + + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&provider_id) else { + return Ok(Vec::new()); + }; + let source_provider_values = provider + .descriptor() + .extends_provider_ids + .into_iter() + .map(|provider_id| provider_id.as_storage_value().to_string()) + .collect::>(); + if source_provider_values.is_empty() { + return Ok(Vec::new()); + } + + let mut current_id = Some(item_id); + let mut item_ids = Vec::new(); + let mut visited = HashSet::new(); + while let Some(current_item_id) = current_id { + if !visited.insert(current_item_id) { + break; + } + item_ids.push(current_item_id); + current_id = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(current_item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(media_items_dsl::parent_id) + .first::>(conn) + .optional()? + .flatten(); + } + if item_ids.is_empty() { + return Ok(Vec::new()); + } + + let collection_item_rows = collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::media_item_id.eq_any(&item_ids)) + .select(MetadataCollectionItem::as_select()) + .load::(conn)?; + if collection_item_rows.is_empty() { + return Ok(Vec::new()); + } + + let collection_item_by_collection_id = collection_item_rows + .into_iter() + .map(|item| (item.collection_id, item)) + .collect::>(); + let mut collection_rows = collections_dsl::metadata_collections + .filter( + collections_dsl::id.eq_any( + collection_item_by_collection_id + .keys() + .copied() + .collect::>(), + ), + ) + .filter(collections_dsl::provider_id.eq_any(&source_provider_values)) + .filter(collections_dsl::relation_kind.eq("primary")) + .select(MetadataCollection::as_select()) + .load::(conn)?; + + let source_provider_rank = source_provider_values + .iter() + .enumerate() + .map(|(index, provider_id)| (provider_id.clone(), index)) + .collect::>(); + let fallback_provider_rank = source_provider_rank.len(); + collection_rows.sort_by(|left, right| { + let left_provider_rank = source_provider_rank + .get(&left.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let right_provider_rank = source_provider_rank + .get(&right.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let left_item_rank = collection_item_by_collection_id + .get(&left.id) + .and_then(|item| { + item_ids + .iter() + .position(|item_id| *item_id == item.media_item_id) + }) + .unwrap_or(item_ids.len()); + let right_item_rank = collection_item_by_collection_id + .get(&right.id) + .and_then(|item| { + item_ids + .iter() + .position(|item_id| *item_id == item.media_item_id) + }) + .unwrap_or(item_ids.len()); + + left_item_rank + .cmp(&right_item_rank) + .then_with(|| left_provider_rank.cmp(&right_provider_rank)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| right.id.cmp(&left.id)) + }); + + let mut seen = HashSet::new(); + Ok(collection_rows + .into_iter() + .filter(|collection| { + seen.insert(( + collection.source_provider_id.clone(), + collection.source_external_id.clone(), + )) + }) + .map(|collection| { + ( + collection.id, + "collection".to_string(), + collection.source_provider_id, + collection.source_external_id, + ) + }) + .collect()) +} + +fn get_item_theme_song_source_references( + conn: &mut SqliteConnection, + item_id: i32, + source_provider_ids: &[MetadataProviderId], + secondary_provider: &(dyn MetadataProvider + Send + Sync), +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut current_id = Some(item_id); + let mut visited = HashSet::new(); + + while let Some(current_item_id) = current_id { + if !visited.insert(current_item_id) { + break; + } + + let Some((parent_id, item_type)) = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(current_item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select((media_items_dsl::parent_id, media_items_dsl::item_type)) + .first::<(Option, String)>(conn) + .optional()? + else { + break; + }; + + let links = + get_item_theme_song_source_metadata_links(conn, current_item_id, source_provider_ids)?; + if !links.is_empty() { + let mut references = Vec::new(); + let mut seen = HashSet::new(); + for link in links { + append_theme_song_source_references( + conn, + secondary_provider, + &item_type, + &link, + &mut references, + &mut seen, + )?; + } + return Ok(references); + } + + current_id = parent_id; + } + + Ok(Vec::new()) +} + +fn append_theme_song_source_references( + conn: &mut SqliteConnection, + secondary_provider: &(dyn MetadataProvider + Send + Sync), + item_type: &str, + link: &ItemMetadataLink, + references: &mut Vec<(String, String, String)>, + seen: &mut HashSet<(String, String, String)>, +) -> Result<(), diesel::result::Error> { + let Some(source_provider_id) = MetadataProviderId::from_storage_value(&link.provider_id) else { + return Ok(()); + }; + if !item_type.trim().is_empty() { + let mut candidates = Vec::new(); + let mut order = 0; + push_supported_secondary_reference_candidate( + secondary_provider, + &source_provider_id, + item_type, + link.provider_id.clone(), + link.external_id.clone(), + order, + &mut candidates, + ); + + for (database_id, external_id) in metadata_external_ids(conn, link.id)? { + order += 1; + push_supported_secondary_reference_candidate( + secondary_provider, + &source_provider_id, + item_type, + database_id, + external_id, + order, + &mut candidates, + ); + } + + candidates.sort_by_key(|candidate| (candidate.priority, candidate.order)); + for candidate in candidates { + let reference = ( + candidate.item_type, + candidate.database_id, + candidate.external_id, + ); + if seen.insert(reference.clone()) { + references.push(reference); + } + } + } + + Ok(()) +} + +fn push_supported_secondary_reference_candidate( + secondary_provider: &(dyn MetadataProvider + Send + Sync), + source_provider_id: &MetadataProviderId, + item_type: &str, + database_id: String, + external_id: String, + order: usize, + candidates: &mut Vec, +) { + let Some(priority) = secondary_provider.secondary_metadata_reference_priority( + source_provider_id, + item_type, + &database_id, + ) else { + return; + }; + + candidates.push(SecondaryMetadataReferenceCandidate { + item_type: item_type.to_string(), + database_id, + external_id, + priority, + order, + }); +} + +fn get_item_theme_song_source_metadata_links( + conn: &mut SqliteConnection, + item_id: i32, + source_provider_ids: &[MetadataProviderId], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + let source_provider_values = source_provider_ids + .iter() + .map(|provider_id| provider_id.as_storage_value().to_string()) + .collect::>(); + let source_provider_rank = source_provider_values + .iter() + .enumerate() + .map(|(index, provider_id)| (provider_id.clone(), index)) + .collect::>(); + let fallback_provider_rank = source_provider_rank.len(); + + let mut links = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq_any(&source_provider_values)) + .filter(metadata_links_dsl::relation_kind.eq("primary")) + .filter(metadata_links_dsl::media_type.is_not_null()) + .select(ItemMetadataLink::as_select()) + .load::(conn)?; + + links.sort_by(|left, right| { + let left_provider_rank = source_provider_rank + .get(&left.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let right_provider_rank = source_provider_rank + .get(&right.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + + left_provider_rank + .cmp(&right_provider_rank) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| right.id.cmp(&left.id)) + }); + + Ok(links) +} + +fn metadata_external_ids( + conn: &mut SqliteConnection, + metadata_link_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_external_ids::dsl as external_ids_dsl; + + external_ids_dsl::item_metadata_external_ids + .filter(external_ids_dsl::metadata_link_id.eq(metadata_link_id)) + .order((external_ids_dsl::source.asc(), external_ids_dsl::id.asc())) + .select((external_ids_dsl::source, external_ids_dsl::external_id)) + .load::<(String, String)>(conn) +} + +/// Resolve a local subtitle asset path for a media item by track index. +pub fn resolve_item_subtitle_path( + conn: &mut SqliteConnection, + item_id: i32, + track_index: usize, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + Ok(discover_item_assets(item_id, &source_path, data_dir) + .subtitle_paths + .into_iter() + .nth(track_index)) +} + +/// Resolve a local poster or backdrop asset path for a media item. +pub fn resolve_local_item_artwork_path( + conn: &mut SqliteConnection, + item_id: i32, + kind: ArtworkKind, + data_dir: &str, +) -> Result, diesel::result::Error> { + let Some(source_path) = resolve_media_item_source_path(conn, item_id)? else { + return Ok(None); + }; + + let assets = discover_item_assets(item_id, &source_path, data_dir); + Ok(match kind { + ArtworkKind::Poster => assets.poster_path, + ArtworkKind::Backdrop => assets.backdrop_path, + ArtworkKind::Logo => None, + }) +} + +/// Store or update playback progress for one media item. +pub fn upsert_playback_progress( + conn: &mut SqliteConnection, + user_id: i32, + item_id: i32, + position_ms: i64, + duration_ms: Option, + completed: bool, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let existing = playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .filter(playback_progress_dsl::media_item_id.eq(item_id)) + .select(PlaybackProgress::as_select()) + .first(conn) + .optional()?; + + let now = current_timestamp(); + let completed_transition = completed && existing.as_ref().is_none_or(|row| !row.completed); + let watch_count = existing + .as_ref() + .map(|row| row.watch_count) + .unwrap_or_default() + .saturating_add(if completed_transition { 1 } else { 0 }); + let last_watched_at = if completed_transition { + Some(now) + } else { + existing.as_ref().and_then(|row| row.last_watched_at) + }; + let progress = NewPlaybackProgress { + user_id, + media_item_id: item_id, + position_ms, + duration_ms, + completed, + watch_count, + last_watched_at, + updated_at: Some(now), + }; + + if let Some(existing) = existing { + diesel::update( + playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::id.eq(existing.id)), + ) + .set(&progress) + .execute(conn)?; + } else { + diesel::insert_into(playback_progress_dsl::playback_progress) + .values(&progress) + .execute(conn)?; + } + + Ok(()) +} + +/// Return saved playback progress for a user and item. +pub fn get_user_playback_progress( + conn: &mut SqliteConnection, + user_id: Option, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let Some(user_id) = user_id else { + return Ok(None); + }; + + playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .filter(playback_progress_dsl::media_item_id.eq(item_id)) + .select(PlaybackProgress::as_select()) + .first(conn) + .optional() +} + +fn apply_playback_progress_to_summary( + summary: &mut MediaItemSummary, + progress: &PlaybackProgress, +) { + apply_playback_values_to_summary( + summary, + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.watch_count, + progress.last_watched_at, + ); +} + +fn apply_playback_progress_to_detail( + detail: &mut MediaItemDetail, + progress: &PlaybackProgress, +) { + apply_playback_values_to_detail( + detail, + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.watch_count, + progress.last_watched_at, + ); +} + +fn apply_playback_values_to_summary( + summary: &mut MediaItemSummary, + position_ms: i64, + duration_ms: Option, + completed: bool, + watch_count: i32, + last_watched_at: Option, +) { + summary.playback_position_ms = Some(position_ms); + summary.playback_duration_ms = duration_ms; + summary.playback_completed = completed; + summary.watch_count = watch_count; + summary.last_watched_at = last_watched_at; +} + +fn apply_playback_values_to_detail( + detail: &mut MediaItemDetail, + position_ms: i64, + duration_ms: Option, + completed: bool, + watch_count: i32, + last_watched_at: Option, +) { + detail.playback_position_ms = Some(position_ms); + detail.playback_duration_ms = duration_ms; + detail.playback_completed = completed; + detail.watch_count = watch_count; + detail.last_watched_at = last_watched_at; +} + +fn playback_progress_by_item_id( + conn: &mut SqliteConnection, + user_id: Option, + item_ids: &[i32], +) -> Result, diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let Some(user_id) = user_id else { + return Ok(HashMap::new()); + }; + if item_ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .filter(playback_progress_dsl::media_item_id.eq_any(item_ids)) + .select(PlaybackProgress::as_select()) + .load::(conn)? + .into_iter() + .map(|progress| (progress.media_item_id, progress)) + .collect()) +} + +fn apply_user_playback_progress_to_summaries( + conn: &mut SqliteConnection, + user_id: Option, + items: &mut [MediaItemSummary], +) -> Result<(), diesel::result::Error> { + let item_ids = items.iter().map(|item| item.id).collect::>(); + let progress_by_item_id = playback_progress_by_item_id(conn, user_id, &item_ids)?; + + for item in items { + if let Some(progress) = progress_by_item_id.get(&item.id) { + apply_playback_progress_to_summary(item, progress); + } else if let Some(progress) = container_playback_progress(conn, user_id, item.id)? { + apply_playback_values_to_summary( + item, + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.watch_count, + progress.last_watched_at, + ); + } + } + + Ok(()) +} + +/// Apply current-user playback state and container playback targets to an item detail response. +pub fn apply_user_playback_context_to_detail( + conn: &mut SqliteConnection, + user_id: Option, + detail: &mut MediaItemDetail, +) -> Result<(), diesel::result::Error> { + let mut item_ids = vec![detail.id]; + item_ids.extend(detail.hierarchy.iter().map(|item| item.id)); + item_ids.extend(detail.children.iter().map(|item| item.id)); + let progress_by_item_id = playback_progress_by_item_id(conn, user_id, &item_ids)?; + + if let Some(progress) = progress_by_item_id.get(&detail.id) { + apply_playback_progress_to_detail(detail, progress); + } else if let Some(progress) = container_playback_progress(conn, user_id, detail.id)? { + apply_playback_values_to_detail( + detail, + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.watch_count, + progress.last_watched_at, + ); + } + for item in &mut detail.hierarchy { + if let Some(progress) = progress_by_item_id.get(&item.id) { + apply_playback_progress_to_summary(item, progress); + } + } + for item in &mut detail.children { + if let Some(progress) = progress_by_item_id.get(&item.id) { + apply_playback_progress_to_summary(item, progress); + } else if let Some(progress) = container_playback_progress(conn, user_id, item.id)? { + apply_playback_values_to_summary( + item, + progress.position_ms, + progress.duration_ms, + progress.completed, + progress.watch_count, + progress.last_watched_at, + ); + } + } + + let Some(item) = load_media_item_row(conn, detail.id)? else { + return Ok(()); + }; + let targets = container_playback_targets(conn, user_id, &item)?; + detail.playback_target = targets.primary; + detail.restart_playback_target = targets.restart; + + Ok(()) +} + +#[derive(Debug, Clone)] +struct PlaybackProgressValues { + position_ms: i64, + duration_ms: Option, + completed: bool, + watch_count: i32, + last_watched_at: Option, +} + +fn container_playback_progress( + conn: &mut SqliteConnection, + user_id: Option, + item_id: i32, +) -> Result, diesel::result::Error> { + let Some(item) = load_media_item_row(conn, item_id)? else { + return Ok(None); + }; + if !matches!(item.item_type.as_str(), "show" | "season") { + return Ok(None); + } + + let episodes = playable_episode_targets(conn, &item)?; + if episodes.is_empty() { + return Ok(None); + } + + let episode_ids = episodes + .iter() + .map(|episode| episode.item.id) + .collect::>(); + let progress_by_item_id = playback_progress_by_item_id(conn, user_id, &episode_ids)?; + if progress_by_item_id.is_empty() { + return Ok(None); + } + + let mut aggregate_position_ms = 0_i64; + let mut aggregate_duration_ms = 0_i64; + let mut fallback_position_units = 0_i64; + let mut fallback_duration_units = 0_i64; + let mut complete_watch_counts = Vec::new(); + let mut last_watched_at = None; + + for episode in &episodes { + fallback_duration_units += 1_000; + let progress = progress_by_item_id.get(&episode.item.id); + let duration_ms = progress + .and_then(|progress| progress.duration_ms) + .or(episode.item.duration_ms) + .filter(|duration| *duration > 0); + + if let Some(duration_ms) = duration_ms { + aggregate_duration_ms = aggregate_duration_ms.saturating_add(duration_ms); + if let Some(progress) = progress { + let episode_position = if progress.watch_count > 0 || progress.completed { + duration_ms + } else { + progress.position_ms.clamp(0, duration_ms) + }; + aggregate_position_ms = aggregate_position_ms.saturating_add(episode_position); + } + } + + let Some(progress) = progress else { + complete_watch_counts.push(0); + continue; + }; + + if progress.watch_count > 0 || progress.completed { + fallback_position_units += 1_000; + } else if progress.position_ms > 0 { + fallback_position_units += 500; + } + complete_watch_counts.push(progress.watch_count); + last_watched_at = last_watched_at.max(progress.last_watched_at); + } + + let watch_count = complete_watch_counts.into_iter().min().unwrap_or_default(); + let completed = watch_count > 0; + let (position_ms, duration_ms) = if aggregate_duration_ms > 0 { + (aggregate_position_ms, Some(aggregate_duration_ms)) + } else { + (fallback_position_units, Some(fallback_duration_units)) + }; + + if position_ms <= 0 && watch_count == 0 && last_watched_at.is_none() { + return Ok(None); + } + + Ok(Some(PlaybackProgressValues { + position_ms, + duration_ms, + completed, + watch_count, + last_watched_at, + })) +} + +#[derive(Debug, Default)] +struct ContainerPlaybackTargets { + primary: Option, + restart: Option, +} + +#[derive(Debug, Clone)] +struct PlayableEpisodeTarget { + item: MediaItem, + season_number: Option, + episode_number: Option, +} + +fn container_playback_targets( + conn: &mut SqliteConnection, + user_id: Option, + item: &MediaItem, +) -> Result { + if !matches!(item.item_type.as_str(), "show" | "season") { + return Ok(ContainerPlaybackTargets::default()); + } + + let episodes = playable_episode_targets(conn, item)?; + let Some(first_episode) = episodes.first() else { + return Ok(ContainerPlaybackTargets::default()); + }; + + let episode_ids = episodes + .iter() + .map(|episode| episode.item.id) + .collect::>(); + let progress_by_item_id = playback_progress_by_item_id(conn, user_id, &episode_ids)?; + + let mut show_has_started = false; + let mut latest_resume: Option<(&PlayableEpisodeTarget, &PlaybackProgress, i64)> = None; + for episode in &episodes { + let Some(progress) = progress_by_item_id.get(&episode.item.id) else { + continue; + }; + + if progress.watch_count > 0 + || progress.position_ms > 0 + || progress.last_watched_at.is_some() + { + show_has_started = true; + } + let resume_ms = resumable_playback_position_ms( + progress.position_ms, + progress.duration_ms.or(episode.item.duration_ms), + progress.completed, + ); + if resume_ms <= 0 { + continue; + } + + let updated_at = progress.updated_at.unwrap_or_default(); + if latest_resume + .as_ref() + .is_none_or(|(_, _, existing_updated_at)| updated_at > *existing_updated_at) + { + latest_resume = Some((episode, progress, updated_at)); + } + } + + if let Some((episode, progress, _)) = latest_resume { + let primary = + playback_target_from_episode(episode, progress.position_ms, true, show_has_started); + let restart = restart_playback_target(item, first_episode, &primary, show_has_started); + return Ok(ContainerPlaybackTargets { + primary: Some(primary), + restart, + }); + } + + let next_episode = episodes + .iter() + .find(|episode| { + progress_by_item_id + .get(&episode.item.id) + .is_none_or(|progress| progress.watch_count == 0) + }) + .unwrap_or(first_episode); + let primary = playback_target_from_episode(next_episode, 0, false, show_has_started); + let restart = restart_playback_target(item, first_episode, &primary, show_has_started); + + Ok(ContainerPlaybackTargets { + primary: Some(primary), + restart, + }) +} + +fn restart_playback_target( + container: &MediaItem, + first_episode: &PlayableEpisodeTarget, + primary: &MediaPlaybackTarget, + show_has_started: bool, +) -> Option { + if !show_has_started || (primary.item_id == first_episode.item.id && primary.start_ms == 0) { + return None; + } + + Some(playback_target_from_episode(first_episode, 0, false, false)).map(|mut target| { + target.label = if container.item_type == "season" { + "Start season".into() + } else { + "Start show".into() + }; + target + }) +} + +fn playback_target_from_episode( + episode: &PlayableEpisodeTarget, + start_ms: i64, + resume: bool, + show_has_started: bool, +) -> MediaPlaybackTarget { + let episode_label = episode_number_label(episode.season_number, episode.episode_number) + .unwrap_or_else(|| episode.item.display_title.clone()); + let label = if resume { + format!("Resume {episode_label}") + } else if show_has_started { + format!("Play next {episode_label}") + } else { + format!("Play {episode_label}") + }; + + MediaPlaybackTarget { + item_id: episode.item.id, + start_ms, + label, + display_title: episode.item.display_title.clone(), + season_number: episode.season_number, + episode_number: episode.episode_number, + resume, + } +} + +fn episode_number_label( + season_number: Option, + episode_number: Option, +) -> Option { + match (season_number, episode_number) { + (Some(season), Some(episode)) => Some(format!("S{season:02}E{episode:02}")), + (None, Some(episode)) => Some(format!("E{episode:02}")), + _ => None, + } +} + +fn resumable_playback_position_ms( + position_ms: i64, + duration_ms: Option, + completed: bool, +) -> i64 { + if completed || position_ms < 30_000 { + return 0; + } + if let Some(duration_ms) = duration_ms { + if duration_ms > 0 && duration_ms - position_ms < 30_000 { + return 0; + } + } + + position_ms +} + +fn playable_episode_targets( + conn: &mut SqliteConnection, + item: &MediaItem, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut targets = match item.item_type.as_str() { + "show" => { + let seasons = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(item.id)) + .filter(media_items_dsl::item_type.eq("season")) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .select(MediaItem::as_select()) + .load::(conn)?; + let season_numbers = seasons + .iter() + .map(|season| (season.id, season.season_number)) + .collect::>(); + let season_ids = seasons.iter().map(|season| season.id).collect::>(); + if season_ids.is_empty() { + return Ok(Vec::new()); + } + + media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq_any(&season_ids)) + .filter(media_items_dsl::item_type.eq("episode")) + .filter(media_items_dsl::playable.eq(true)) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .select(MediaItem::as_select()) + .load::(conn)? + .into_iter() + .map(|episode| PlayableEpisodeTarget { + season_number: episode.season_number.or_else(|| { + episode + .parent_id + .and_then(|id| season_numbers.get(&id).copied().flatten()) + }), + episode_number: episode.episode_number, + item: episode, + }) + .collect::>() + } + "season" => media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(item.id)) + .filter(media_items_dsl::item_type.eq("episode")) + .filter(media_items_dsl::playable.eq(true)) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .select(MediaItem::as_select()) + .load::(conn)? + .into_iter() + .map(|episode| PlayableEpisodeTarget { + season_number: episode.season_number.or(item.season_number), + episode_number: episode.episode_number, + item: episode, + }) + .collect::>(), + _ => Vec::new(), + }; + + targets.sort_by(|left, right| { + left.season_number + .unwrap_or(i32::MAX) + .cmp(&right.season_number.unwrap_or(i32::MAX)) + .then_with(|| { + left.episode_number + .unwrap_or(i32::MAX) + .cmp(&right.episode_number.unwrap_or(i32::MAX)) + }) + .then_with(|| left.item.display_title.cmp(&right.item.display_title)) + .then_with(|| left.item.id.cmp(&right.item.id)) + }); + + Ok(targets) +} + +fn get_continue_watching_items( + conn: &mut SqliteConnection, + user_id: Option, + library_id: Option, + visible_items: &[MediaItemSummary], +) -> Result, diesel::result::Error> { + use crate::db::schema::playback_progress::dsl as playback_progress_dsl; + + let Some(user_id) = user_id else { + return Ok(Vec::new()); + }; + let visible_items_by_id = visible_items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + + let progress_rows = playback_progress_dsl::playback_progress + .filter(playback_progress_dsl::user_id.eq(user_id)) + .order(playback_progress_dsl::updated_at.desc()) + .select(PlaybackProgress::as_select()) + .load::(conn)?; + + let mut items = Vec::new(); + let mut seen_continue_keys = HashSet::new(); + for progress in progress_rows { + let Some(row) = load_media_item_row(conn, progress.media_item_id)? else { + continue; + }; + + if row.item_type == "episode" { + let Some(episode) = visible_items_by_id.get(&row.id) else { + continue; + }; + let Some(show_id) = root_show_item_id(episode, &visible_items_by_id) else { + continue; + }; + let Some(show) = visible_items_by_id.get(&show_id) else { + continue; + }; + if show.playback_completed + || (show.playback_position_ms.unwrap_or_default() <= 0 + && show.watch_count == 0 + && show.last_watched_at.is_none()) + || !seen_continue_keys.insert(show.id) + { + continue; + } + let mut target = if progress.completed { + let Some(show_row) = load_media_item_row(conn, show.id)? else { + continue; + }; + let Some(target) = + container_playback_targets(conn, Some(user_id), &show_row)?.primary + else { + continue; + }; + let Some(target_item) = visible_items_by_id.get(&target.item_id) else { + continue; + }; + target_item.clone() + } else { + let mut target = episode.clone(); + apply_playback_progress_to_summary(&mut target, &progress); + target + }; + apply_continue_watching_episode_presentation(&mut target, show, &visible_items_by_id); + + if library_id.is_none() || Some(target.library_id) == library_id { + items.push(target); + } + continue; + } + + if progress.completed { + continue; + } + + let Some(mut item) = visible_items_by_id.get(&row.id).cloned() else { + continue; + }; + apply_playback_progress_to_summary(&mut item, &progress); + if (library_id.is_none() || Some(item.library_id) == library_id) + && seen_continue_keys.insert(item.id) + { + items.push(item); + } + } + + Ok(items) +} + +fn apply_continue_watching_episode_presentation( + target: &mut MediaItemSummary, + show: &MediaItemSummary, + items_by_id: &HashMap, +) { + if target.item_type != "episode" { + return; + } + + target.display_title = show.display_title.clone(); + + let season = target + .parent_id + .and_then(|season_id| items_by_id.get(&season_id)); + let season_number = target + .season_number + .or_else(|| season.and_then(|item| item.season_number)); + target.display_subtitle = episode_number_label(season_number, target.episode_number); + + if let Some(season) = season { + target.artwork_item_id = Some(season.id); + target.artwork_updated_at = season.artwork_updated_at.or(target.artwork_updated_at); + } +} + +fn sort_recently_added(items: &[MediaItemSummary]) -> Vec { + let items_by_id = items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let mut show_groups = HashMap::>::new(); + let mut entries = Vec::<(Option, MediaItemSummary)>::new(); + + let mut leaf_items = items + .iter() + .filter(|item| item.child_count == 0) + .cloned() + .collect::>(); + leaf_items.sort_by_key(|item| std::cmp::Reverse(item.modified_at)); + + for item in leaf_items { + if item.item_type == "episode" { + if let Some(show_id) = root_show_item_id(&item, &items_by_id) { + show_groups.entry(show_id).or_default().push(item); + continue; + } + } + + entries.push((item.modified_at, item)); + } + + for (show_id, episodes) in show_groups { + let representative = if episodes.len() == 1 { + episodes[0].clone() + } else { + let unique_season_ids = episodes + .iter() + .filter_map(|episode| episode.parent_id) + .collect::>(); + if unique_season_ids.len() == 1 { + unique_season_ids + .into_iter() + .next() + .and_then(|season_id| items_by_id.get(&season_id).cloned()) + .unwrap_or_else(|| episodes[0].clone()) + } else { + items_by_id + .get(&show_id) + .cloned() + .unwrap_or_else(|| episodes[0].clone()) + } + }; + let modified_at = episodes + .iter() + .filter_map(|episode| episode.modified_at) + .max(); + entries.push((modified_at, representative)); + } + + entries.sort_by(|left, right| { + right + .0 + .cmp(&left.0) + .then_with(|| left.1.display_title.cmp(&right.1.display_title)) + }); + entries.into_iter().map(|(_, item)| item).collect() +} + +fn root_show_item_id( + item: &MediaItemSummary, + items_by_id: &HashMap, +) -> Option { + let mut current = item; + + while let Some(parent_id) = current.parent_id { + let parent = items_by_id.get(&parent_id)?; + if parent.item_type == "show" { + return Some(parent.id); + } + current = parent; + } + + None +} + +fn sort_recommended( + items: &[MediaItemSummary], + continue_watching: &[MediaItemSummary], +) -> Vec { + let items_by_id = items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let continue_ids = recommended_excluded_item_ids(continue_watching, &items_by_id); + let mut seen_recommendations = HashSet::::new(); + + let mut items = items + .iter() + .filter_map(|item| { + if continue_ids.contains(&item.id) { + return None; + } + + let recommendation = recommended_representative_item(item, &items_by_id)?; + if continue_ids.contains(&recommendation.id) + || !seen_recommendations.insert(recommendation.id) + { + return None; + } + + Some(recommendation) + }) + .collect::>(); + items.sort_by(|left, right| { + right + .duration_ms + .unwrap_or_default() + .cmp(&left.duration_ms.unwrap_or_default()) + .then_with(|| right.modified_at.cmp(&left.modified_at)) + }); + items +} + +fn recommended_excluded_item_ids( + continue_watching: &[MediaItemSummary], + items_by_id: &HashMap, +) -> HashSet { + continue_watching + .iter() + .flat_map(|item| { + let mut ids = vec![item.id]; + if let Some(show_id) = root_show_item_id(item, items_by_id) { + ids.push(show_id); + } + ids + }) + .collect() +} + +fn recommended_representative_item( + item: &MediaItemSummary, + items_by_id: &HashMap, +) -> Option { + if matches!(item.item_type.as_str(), "season" | "episode") { + if let Some(show_id) = root_show_item_id(item, items_by_id) { + if let Some(show) = items_by_id.get(&show_id) { + return Some(show.clone()); + } + } + return None; + } + + Some(item.clone()) +} + +/// Return one browser-facing media item summary by its stable identifier. +pub fn get_media_item_summary( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + match load_media_item_row(conn, item_id)? { + Some(row) => Ok(Some(media_item_summary_with_preferred_title(conn, row)?)), + None => Ok(None), + } +} + +/// Return one browser-facing media item summary using the caller's metadata language order. +pub fn get_media_item_summary_with_preferred_languages( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + match load_media_item_row(conn, item_id)? { + Some(row) => Ok(Some(media_item_summary_with_preferred_languages( + conn, + row, + preferred_languages, + )?)), + None => Ok(None), + } +} + +/// Return one media item summary with its browser-facing parent hierarchy. +pub fn get_media_item_summary_with_hierarchy( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], +) -> Result)>, diesel::result::Error> { + let Some(row) = load_media_item_row(conn, item_id)? else { + return Ok(None); + }; + + let hierarchy = load_media_item_hierarchy(conn, &row, preferred_languages)?; + let summary = media_item_summary_with_preferred_languages(conn, row, preferred_languages)?; + Ok(Some((summary, hierarchy))) +} + +fn container_matches( + file_container: &str, + profile_container: &str, +) -> bool { + file_container + .split(',') + .map(|value| value.trim().to_ascii_lowercase()) + .any(|value| { + value == profile_container.to_ascii_lowercase() + || (value == "mov" && matches!(profile_container, "mp4" | "m4v")) + || (value == "matroska" && profile_container == "mkv") + }) +} + +fn codec_matches( + file_codec: &str, + profile_codec: &str, +) -> bool { + let normalized_file_codec = normalize_codec_name(file_codec); + let normalized_profile_codec = normalize_codec_name(profile_codec); + normalized_file_codec == normalized_profile_codec +} + +fn normalize_codec_name(codec: &str) -> String { + let normalized = codec.trim().to_ascii_lowercase().replace(['-', '_'], ""); + + match normalized.as_str() { + "avc1" | "avc" | "h264" | "x264" => "h264".into(), + "hev1" | "hvc1" | "hevc" | "h265" | "x265" => "hevc".into(), + "mpeg4" | "mp4v" => "mpeg4".into(), + "aac" | "aaclc" | "mp4a" => "aac".into(), + "eac3" | "eac" => "eac3".into(), + "ac3" | "ac-3" => "ac3".into(), + "vorbis" => "vorbis".into(), + "opus" => "opus".into(), + "flac" => "flac".into(), + "mp3" | "mpeg3" | "mpga" => "mp3".into(), + "vp8" => "vp8".into(), + "vp9" => "vp9".into(), + "av1" => "av1".into(), + _ => normalized, + } +} + +fn within_resolution_limits( + file: &CatalogMediaFile, + profile: &ClientProfile, +) -> bool { + if profile.max_video_width > 0 { + if let Some(w) = file.width { + if w as u32 > profile.max_video_width { + return false; + } + } + } + if profile.max_video_height > 0 { + if let Some(h) = file.height { + if h as u32 > profile.max_video_height { + return false; + } + } + } + true +} + +fn can_client_direct_play( + file: &CatalogMediaFile, + profile: &ClientProfile, +) -> bool { + let extension = Path::new(&file.relative_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + let is_supported_container = if let Some(container) = file.container.as_deref() { + profile + .supported_containers + .iter() + .any(|c| container_matches(container, c)) + } else { + // Fallback to extension check if container is missing + extension + .as_deref() + .map(|ext| { + profile + .supported_containers + .iter() + .any(|c| c.eq_ignore_ascii_case(ext)) + }) + .unwrap_or(false) + }; + + let is_supported_video = if let Some(codec) = file.video_codec.as_deref() { + profile + .supported_video_codecs + .iter() + .any(|c| codec_matches(codec, c)) + } else { + true // No video track -> skip check + }; + + let is_supported_audio = if let Some(codec) = file.audio_codec.as_deref() { + profile + .supported_audio_codecs + .iter() + .any(|c| codec_matches(codec, c)) + } else { + true // No audio track -> skip check + }; + + is_supported_container + && is_supported_video + && is_supported_audio + && within_resolution_limits(file, profile) +} + +fn detect_mime_type(file: &CatalogMediaFile) -> Option { + let extension = Path::new(&file.relative_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + match extension.as_deref() { + Some("mp4" | "m4v") => Some("video/mp4".into()), + Some("webm") => Some("video/webm".into()), + Some("mp3") => Some("audio/mpeg".into()), + Some("m4a") => Some("audio/mp4".into()), + Some("ogg" | "opus") => Some("audio/ogg".into()), + Some("wav") => Some("audio/wav".into()), + Some("flac") => Some("audio/flac".into()), + _ => None, + } +} + +impl LibraryScanStatus { + fn as_storage_value(&self) -> &'static str { + match self { + LibraryScanStatus::NeverScanned => "never_scanned", + LibraryScanStatus::Available => "available", + LibraryScanStatus::EmptyPath => "empty_path", + LibraryScanStatus::MissingPath => "missing_path", + LibraryScanStatus::NotDirectory => "not_directory", + LibraryScanStatus::Unreadable => "unreadable", + } + } + + fn from_storage_value(value: &str) -> Self { + match value.trim() { + "available" => LibraryScanStatus::Available, + "empty_path" => LibraryScanStatus::EmptyPath, + "missing_path" => LibraryScanStatus::MissingPath, + "not_directory" => LibraryScanStatus::NotDirectory, + "unreadable" => LibraryScanStatus::Unreadable, + _ => LibraryScanStatus::NeverScanned, + } + } +} + +impl MediaLibraryKind { + fn as_storage_value(&self) -> String { + match self { + MediaLibraryKind::Mixed => "mixed", + MediaLibraryKind::Movies => "movies", + MediaLibraryKind::Shows => "shows", + MediaLibraryKind::Music => "music", + MediaLibraryKind::Photos => "photos", + MediaLibraryKind::Books => "books", + MediaLibraryKind::HomeVideos => "home_videos", + } + .to_string() + } + + fn from_storage_value(value: &str) -> Self { + match value.trim() { + "movies" => MediaLibraryKind::Movies, + "shows" => MediaLibraryKind::Shows, + "music" => MediaLibraryKind::Music, + "photos" => MediaLibraryKind::Photos, + "books" => MediaLibraryKind::Books, + "home_videos" => MediaLibraryKind::HomeVideos, + _ => MediaLibraryKind::Mixed, + } + } +} + +fn media_library_settings_from_row(row: MediaLibrary) -> MediaLibrarySettings { + let mut paths = serde_json::from_str::>(&row.paths_json).unwrap_or_default(); + if paths.is_empty() { + paths = parse_library_storage_paths(&row.path); + } + if paths.is_empty() { + paths.push(row.path.clone()); + } + + let mut metadata_providers = serde_json::from_str::>(&row.metadata_providers_json) + .unwrap_or_default() + .into_iter() + .filter_map(|value| MetadataProviderId::from_storage_value(&value)) + .collect::>(); + if metadata_providers.is_empty() { + metadata_providers.push(MetadataProviderId::Tmdb); + } + let metadata_languages = serde_json::from_str::>(&row.metadata_languages_json) + .unwrap_or_else(|_| vec!["en-US".into()]); + let allowed_user_ids = + serde_json::from_str::>(&row.allowed_user_ids_json).unwrap_or_default(); + let metadata_language_mode = match row.metadata_language_mode.as_str() { + "manual" => MediaLibraryMetadataLanguageMode::Manual, + _ => MediaLibraryMetadataLanguageMode::Auto, + }; + + let mut library = MediaLibrarySettings { + name: row.name, + path: paths.first().cloned().unwrap_or_default(), + paths, + recursive: row.recursive, + kind: MediaLibraryKind::from_storage_value(&row.kind), + scanner: MediaLibraryScanner::from_storage_value(&row.scanner), + metadata_providers, + metadata_language_mode, + metadata_languages, + allowed_user_ids, + }; + library.normalize(); + library +} + +fn media_library_record_values(library: &MediaLibrarySettings) -> NewMediaLibrary { + let mut normalized = library.clone(); + normalized.normalize(); + let primary_path = normalized.primary_path(); + + NewMediaLibrary { + name: normalized.name, + path: primary_path, + paths_json: serde_json::to_string(&normalized.paths).unwrap_or_else(|_| "[]".into()), + kind: normalized.kind.as_storage_value(), + scanner: normalized.scanner.as_storage_value().to_string(), + recursive: normalized.recursive, + metadata_providers_json: serde_json::to_string( + &normalized + .metadata_providers + .iter() + .map(|provider| provider.as_storage_value()) + .collect::>(), + ) + .unwrap_or_else(|_| "[\"tmdb\"]".into()), + metadata_language_mode: match normalized.metadata_language_mode { + MediaLibraryMetadataLanguageMode::Auto => "auto", + MediaLibraryMetadataLanguageMode::Manual => "manual", + } + .into(), + metadata_languages_json: serde_json::to_string(&normalized.metadata_languages) + .unwrap_or_else(|_| "[\"en-US\"]".into()), + allowed_user_ids_json: serde_json::to_string(&normalized.allowed_user_ids) + .unwrap_or_else(|_| "[]".into()), + } +} + +fn insert_media_library( + conn: &mut SqliteConnection, + library: &MediaLibrarySettings, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + diesel::insert_into(media_libraries_dsl::media_libraries) + .values(&media_library_record_values(library)) + .execute(conn)?; + Ok(()) +} + +fn update_media_library( + conn: &mut SqliteConnection, + library_id: i32, + library: &MediaLibrarySettings, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + diesel::update( + media_libraries_dsl::media_libraries.filter(media_libraries_dsl::id.eq(library_id)), + ) + .set(&media_library_record_values(library)) + .execute(conn)?; + Ok(()) +} + +impl DiscoveredMediaFile { + fn physical_path(&self) -> String { + self.full_path.to_string_lossy().to_string() + } + + fn to_new_media_file( + &self, + metadata: ExtractedMetadata, + ) -> NewMediaFile { + NewMediaFile { + path: self.physical_path(), + file_size: self.file_size, + modified_at: self.modified_at, + media_kind: self.media_kind.clone(), + file_hash: self.file_hash.clone(), + container: metadata.container, + duration_ms: metadata.duration_ms, + bit_rate: metadata.bit_rate, + width: metadata.width, + height: metadata.height, + video_codec: metadata.video_codec, + audio_codec: metadata.audio_codec, + metadata_json: metadata.metadata_json, + metadata_updated_at: metadata.metadata_updated_at, + } + } + + fn to_new_media_file_library( + &self, + media_file_id: i32, + library_id: i32, + display_title: Option, + metadata_match_attempted_at: Option, + ) -> NewMediaFileLibrary { + NewMediaFileLibrary { + media_file_id, + library_id, + source_root_path: self.source_root_path.clone(), + relative_path: self.relative_path.clone(), + display_title, + metadata_match_attempted_at, + media_item_id: None, + missing_since: None, + deleted_at: None, + } + } +} + +fn extracted_metadata_from_existing(existing: &CatalogMediaFile) -> ExtractedMetadata { + ExtractedMetadata { + container: existing.container.clone(), + duration_ms: existing.duration_ms, + bit_rate: existing.bit_rate, + width: existing.width, + height: existing.height, + video_codec: existing.video_codec.clone(), + audio_codec: existing.audio_codec.clone(), + metadata_json: existing.metadata_json.clone(), + metadata_updated_at: existing.metadata_updated_at, + } +} + +fn extracted_metadata_from_file_row(existing: &MediaFile) -> ExtractedMetadata { + ExtractedMetadata { + container: existing.container.clone(), + duration_ms: existing.duration_ms, + bit_rate: existing.bit_rate, + width: existing.width, + height: existing.height, + video_codec: existing.video_codec.clone(), + audio_codec: existing.audio_codec.clone(), + metadata_json: existing.metadata_json.clone(), + metadata_updated_at: existing.metadata_updated_at, + } +} + +fn default_metadata(_file: &DiscoveredMediaFile) -> ExtractedMetadata { + ExtractedMetadata::default() +} + +fn extract_metadata( + file: &DiscoveredMediaFile, + probe_context: ProbeContext<'_>, +) -> ExtractedMetadata { + if !matches!(file.media_kind.as_str(), "video" | "audio") { + return default_metadata(file); + } + + if !probe_context.enabled { + return default_metadata(file); + } + + let output = Command::new(probe_context.ffprobe_path) + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + ]) + .arg(&file.full_path) + .output(); + + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + parse_ffprobe_metadata(&stdout).unwrap_or_else(|| default_metadata(file)) + } + _ => default_metadata(file), + } +} + +fn parse_ffprobe_metadata(raw_json: &str) -> Option { + let parsed: Value = serde_json::from_str(raw_json).ok()?; + let format = parsed.get("format"); + let streams = parsed + .get("streams") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let video_stream = streams + .iter() + .find(|stream| stream.get("codec_type").and_then(Value::as_str) == Some("video")); + let audio_stream = streams + .iter() + .find(|stream| stream.get("codec_type").and_then(Value::as_str) == Some("audio")); + + Some(ExtractedMetadata { + container: format + .and_then(|format| format.get("format_name")) + .and_then(Value::as_str) + .map(|value| value.split(',').next().unwrap_or(value).trim().to_string()) + .filter(|value| !value.is_empty()), + duration_ms: format + .and_then(|format| format.get("duration")) + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()) + .map(|value| (value * 1000.0).round() as i64), + bit_rate: format + .and_then(|format| format.get("bit_rate")) + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()), + width: video_stream + .and_then(|stream| stream.get("width")) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + height: video_stream + .and_then(|stream| stream.get("height")) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + video_codec: video_stream + .and_then(|stream| stream.get("codec_name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + audio_codec: audio_stream + .and_then(|stream| stream.get("codec_name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + metadata_json: Some(raw_json.to_string()), + metadata_updated_at: Some(current_timestamp()), + }) +} + +/// Return audio stream summaries from stored ffprobe JSON. +pub fn audio_tracks_from_metadata_json(raw_json: Option<&str>) -> Option> { + let parsed: Value = serde_json::from_str(raw_json?).ok()?; + let streams = parsed.get("streams")?.as_array()?; + let mut audio_index = 0usize; + let tracks = streams + .iter() + .filter_map(|stream| { + if stream.get("codec_type").and_then(Value::as_str) != Some("audio") { + return None; + } + + let index = audio_index; + audio_index += 1; + let codec = stream + .get("codec_name") + .and_then(Value::as_str) + .map(str::to_string); + let tags = stream.get("tags"); + let language = tags + .and_then(|tags| tags.get("language")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "und") + .map(str::to_string); + let title = tags + .and_then(|tags| tags.get("title")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let default = stream + .get("disposition") + .and_then(|value| value.get("default")) + .and_then(Value::as_i64) + .unwrap_or_default() + == 1; + let label = title + .map(str::to_string) + .or_else(|| { + language + .as_ref() + .map(|language| language.to_ascii_uppercase()) + }) + .or_else(|| codec.as_ref().map(|codec| codec.to_ascii_uppercase())) + .unwrap_or_else(|| format!("Audio {}", index + 1)); + + Some(MediaAudioTrack { + index, + label, + codec, + language, + default, + }) + }) + .collect::>(); + + Some(tracks) +} + +/// Select the best source audio stream for the user's preferred languages. +pub fn preferred_audio_stream_index( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + let Some(file) = load_backing_media_file(conn, item_id)? else { + return Ok(None); + }; + let tracks = audio_tracks_from_metadata_json(file.metadata_json.as_deref()).unwrap_or_default(); + Ok(match_audio_track_index(&tracks, preferred_languages)) +} + +fn match_audio_track_index( + tracks: &[MediaAudioTrack], + preferred_languages: &[String], +) -> Option { + if tracks.len() <= 1 { + return None; + } + + let preferred = preferred_languages + .iter() + .flat_map(|language| audio_language_match_keys(language)) + .collect::>(); + for language in preferred { + if let Some(track) = tracks.iter().find(|track| { + track + .language + .as_deref() + .map(audio_language_match_keys) + .unwrap_or_default() + .iter() + .any(|candidate| candidate == &language) + }) { + return Some(track.index); + } + } + + tracks + .iter() + .find(|track| track.default) + .map(|track| track.index) +} + +fn audio_language_match_keys(language: &str) -> Vec { + let normalized = language.trim().to_ascii_lowercase().replace('_', "-"); + let primary = normalized.split('-').next().unwrap_or("").to_string(); + let mut keys = Vec::new(); + for key in [ + normalized.as_str(), + primary.as_str(), + ] { + if !key.is_empty() && !keys.iter().any(|entry| entry == key) { + keys.push(key.to_string()); + } + } + let aliases = match primary.as_str() { + "en" | "eng" | "english" => &["en", "eng", "english"][..], + "es" | "spa" | "spanish" => &["es", "spa", "spanish"][..], + "fr" | "fra" | "fre" | "french" => &["fr", "fra", "fre", "french"][..], + "de" | "deu" | "ger" | "german" => &["de", "deu", "ger", "german"][..], + "it" | "ita" | "italian" => &["it", "ita", "italian"][..], + "ja" | "jpn" | "japanese" => &["ja", "jpn", "japanese"][..], + "pt" | "por" | "portuguese" => &["pt", "por", "portuguese"][..], + _ => &[][..], + }; + for alias in aliases { + if !keys.iter().any(|entry| entry == alias) { + keys.push((*alias).to_string()); + } + } + keys +} + +fn to_persisted_file_summary(row: CatalogMediaFile) -> PersistedMediaFileSummary { + PersistedMediaFileSummary { + id: row.id, + library_id: row.library_id, + relative_path: row.relative_path.clone(), + file_size: row.file_size, + modified_at: row.modified_at, + media_kind: row.media_kind, + file_hash: row.file_hash, + display_title: row + .display_title + .unwrap_or_else(|| fallback_title_from_relative_path(&row.relative_path)), + container: row.container, + duration_ms: row.duration_ms, + width: row.width, + height: row.height, + video_codec: row.video_codec, + audio_codec: row.audio_codec, + missing_since: row.missing_since, + } +} + +fn to_media_item_summary(item: MediaItem) -> MediaItemSummary { + let relative_path = item.relative_path.unwrap_or_default(); + + MediaItemSummary { + id: item.id, + library_id: item.library_id, + parent_id: item.parent_id, + item_type: item.item_type.clone(), + display_title: item.display_title, + display_subtitle: None, + artwork_item_id: None, + relative_path, + media_kind: item + .media_kind + .unwrap_or_else(|| default_media_kind_for_item_type(&item.item_type).to_string()), + playable: item.playable, + child_count: item.child_count, + available_season_count: None, + season_number: item.season_number, + episode_number: item.episode_number, + duration_ms: item.duration_ms, + width: None, + height: None, + genres: Vec::new(), + overview: None, + backdrop_url: None, + logo_url: None, + has_metadata: false, + metadata_refresh_state: None, + metadata_refresh_error: None, + artwork_updated_at: None, + modified_at: item.modified_at, + playback_position_ms: None, + playback_duration_ms: None, + playback_completed: false, + watch_count: 0, + last_watched_at: None, + missing_since: item.missing_since, + } +} + +fn apply_available_season_counts_from_summaries(items: &mut [MediaItemSummary]) { + let show_ids = items + .iter() + .filter(|item| item.item_type == "show") + .map(|item| item.id) + .collect::>(); + if show_ids.is_empty() { + return; + } + + let season_show_ids = items + .iter() + .filter(|item| item.item_type == "season" && item.missing_since.is_none()) + .filter_map(|season| { + let show_id = season.parent_id?; + show_ids.contains(&show_id).then_some((season.id, show_id)) + }) + .collect::>(); + + let mut available_seasons_by_show = HashMap::>::new(); + for episode in items + .iter() + .filter(|item| item.item_type == "episode" && item.playable && item.missing_since.is_none()) + { + let Some(season_id) = episode.parent_id else { + continue; + }; + let Some(show_id) = season_show_ids.get(&season_id).copied() else { + continue; + }; + available_seasons_by_show + .entry(show_id) + .or_default() + .insert(season_id); + } + + for item in items.iter_mut().filter(|item| item.item_type == "show") { + item.available_season_count = Some( + available_seasons_by_show + .get(&item.id) + .map(HashSet::len) + .and_then(|count| i32::try_from(count).ok()) + .unwrap_or(i32::MAX), + ); + } +} + +fn apply_available_season_count( + conn: &mut SqliteConnection, + summary: &mut MediaItemSummary, +) -> Result<(), diesel::result::Error> { + if summary.item_type == "show" { + summary.available_season_count = Some(available_season_count_for_show(conn, summary.id)?); + } + + Ok(()) +} + +fn available_season_count_for_show( + conn: &mut SqliteConnection, + show_id: i32, +) -> Result { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let season_ids = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(show_id)) + .filter(media_items_dsl::item_type.eq("season")) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .select(media_items_dsl::id) + .load::(conn)?; + + let mut count = 0_i32; + for season_id in season_ids { + let available_episode_count = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(season_id)) + .filter(media_items_dsl::item_type.eq("episode")) + .filter(media_items_dsl::playable.eq(true)) + .filter(media_items_dsl::deleted_at.is_null()) + .filter(media_items_dsl::missing_since.is_null()) + .count() + .get_result::(conn)?; + if available_episode_count > 0 { + count = count.saturating_add(1); + } + } + + Ok(count) +} + +fn load_media_item_row( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + media_items_dsl::media_items + .filter(media_items_dsl::id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .first(conn) + .optional() +} + +fn get_media_item_summary_without_metadata( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + let Some(row) = load_media_item_row(conn, item_id)? else { + return Ok(None); + }; + + let mut summary = to_media_item_summary(row); + apply_available_season_count(conn, &mut summary)?; + Ok(Some(summary)) +} + +fn media_item_summary_with_preferred_title( + conn: &mut SqliteConnection, + row: MediaItem, +) -> Result { + media_item_summary_with_preferred_languages(conn, row, &[]) +} + +fn media_item_summary_with_preferred_languages( + conn: &mut SqliteConnection, + row: MediaItem, + preferred_languages: &[String], +) -> Result { + let mut summary = to_media_item_summary(row); + apply_available_season_count(conn, &mut summary)?; + let link = prioritized_primary_metadata_links_for_item(conn, &summary, preferred_languages)? + .into_iter() + .next(); + let link = link.as_ref().map(summary_metadata_link_from_full_link); + apply_primary_metadata_link(&mut summary, link.as_ref()); + + Ok(summary) +} + +fn summary_metadata_link_from_full_link(link: &ItemMetadataLink) -> SummaryMetadataLink { + SummaryMetadataLink { + media_item_id: link.media_item_id, + title: link.title.clone(), + overview: link.overview.clone(), + genres_json: link.genres_json.clone(), + logo_url: link.logo_url.clone(), + cached_logo_path: link.cached_logo_path.clone(), + backdrop_url: link.backdrop_url.clone(), + cached_backdrop_path: link.cached_backdrop_path.clone(), + refresh_state: link.refresh_state.clone(), + refresh_error: link.refresh_error.clone(), + updated_at: link.updated_at, + locale_key: link.locale_key.clone(), + } +} + +fn apply_primary_metadata_link( + summary: &mut MediaItemSummary, + link: Option<&SummaryMetadataLink>, +) { + let Some(link) = link else { + return; + }; + + if let Some(title) = link + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + summary.display_title = title.to_string(); + } + summary.genres = link + .genres_json + .as_deref() + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(); + summary.overview = link.overview.clone(); + if link.cached_backdrop_path.is_some() || link.backdrop_url.is_some() { + summary.backdrop_url = Some(format!( + "/api/v1/items/{}/artwork?kind=backdrop", + summary.id + )); + } + if link.cached_logo_path.is_some() || link.logo_url.is_some() { + summary.logo_url = Some(format!("/api/v1/items/{}/artwork?kind=logo", summary.id)); + } + summary.has_metadata = true; + summary.metadata_refresh_state = Some(link.refresh_state.clone()); + summary.metadata_refresh_error = link.refresh_error.clone(); + summary.artwork_updated_at = link.updated_at; +} + +fn preferred_metadata_links_by_item_id( + conn: &mut SqliteConnection, + item_ids: &[i32], + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + + if item_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows = item_metadata_links_dsl::item_metadata_links + .filter(item_metadata_links_dsl::media_item_id.eq_any(item_ids)) + .filter(item_metadata_links_dsl::relation_kind.eq("primary")) + .order(( + item_metadata_links_dsl::media_item_id.asc(), + item_metadata_links_dsl::updated_at.desc(), + item_metadata_links_dsl::id.desc(), + )) + .select(( + item_metadata_links_dsl::media_item_id, + item_metadata_links_dsl::title, + item_metadata_links_dsl::overview, + item_metadata_links_dsl::genres_json, + item_metadata_links_dsl::logo_url, + item_metadata_links_dsl::cached_logo_path, + item_metadata_links_dsl::backdrop_url, + item_metadata_links_dsl::cached_backdrop_path, + item_metadata_links_dsl::refresh_state, + item_metadata_links_dsl::refresh_error, + item_metadata_links_dsl::updated_at, + item_metadata_links_dsl::locale_key, + )) + .load::<( + i32, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + String, + Option, + Option, + String, + )>(conn)? + .into_iter() + .map( + |( + media_item_id, + title, + overview, + genres_json, + logo_url, + cached_logo_path, + backdrop_url, + cached_backdrop_path, + refresh_state, + refresh_error, + updated_at, + locale_key, + )| SummaryMetadataLink { + media_item_id, + title, + overview, + genres_json, + logo_url, + cached_logo_path, + backdrop_url, + cached_backdrop_path, + refresh_state, + refresh_error, + updated_at, + locale_key, + }, + ) + .collect::>(); + + let language_rank = preferred_languages + .iter() + .enumerate() + .map(|(index, language)| (normalize_locale_key(language), index)) + .collect::>(); + let default_rank = preferred_languages.len(); + let mut by_item_id = HashMap::new(); + for row in rows { + let next_rank = language_rank + .get(&normalize_locale_key(&row.locale_key)) + .copied() + .unwrap_or(default_rank); + match by_item_id.entry(row.media_item_id) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((next_rank, row)); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + if next_rank < entry.get().0 { + entry.insert((next_rank, row)); + } + } + } + } + + Ok(by_item_id + .into_iter() + .map(|(item_id, (_rank, link))| (item_id, link)) + .collect()) +} + +/// Return the best metadata link for a media item, preferring links that +/// structurally match the item's show/season/episode identity. +pub fn get_preferred_item_metadata_link( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + let Some(item) = get_media_item_summary_without_metadata(conn, item_id)? else { + return Ok(None); + }; + + preferred_item_metadata_link_for_summary(conn, &item) +} + +fn preferred_item_metadata_link_for_summary( + conn: &mut SqliteConnection, + item: &MediaItemSummary, +) -> Result, diesel::result::Error> { + let mut links = prioritized_primary_metadata_links_for_item(conn, item, &[])?; + if links.is_empty() { + return Ok(None); + } + + let expected_media_type = expected_metadata_media_type(item); + if let Some(expected_external_id) = expected_tmdb_external_id_for_item(conn, item)? { + if let Some(index) = links.iter().position(|link| { + link.provider_id == MetadataProviderId::Tmdb.as_storage_value() + && link.external_id == expected_external_id + }) { + return Ok(Some(links.swap_remove(index))); + } + + if let Some(expected_media_type) = expected_media_type { + links.retain(|link| { + !(link.provider_id == MetadataProviderId::Tmdb.as_storage_value() + && link.media_type.as_deref() == Some(expected_media_type)) + }); + if links.is_empty() { + return Ok(None); + } + } + } + + if let Some(expected_media_type) = expected_media_type { + if let Some(index) = links + .iter() + .position(|link| link.media_type.as_deref() == Some(expected_media_type)) + { + return Ok(Some(links.swap_remove(index))); + } + } + + Ok(links.into_iter().next()) +} + +fn expected_metadata_media_type(item: &MediaItemSummary) -> Option<&'static str> { + match item.item_type.as_str() { + "episode" => Some("tv_episode"), + "season" => Some("tv_season"), + "show" => Some("tv"), + "movie" => Some("movie"), + _ => None, + } +} + +fn expected_tmdb_external_id_for_item( + conn: &mut SqliteConnection, + item: &MediaItemSummary, +) -> Result, diesel::result::Error> { + match item.item_type.as_str() { + "season" => { + let Some(show_external_id) = show_tmdb_external_id_for_item(conn, item)? else { + return Ok(None); + }; + let Some(season_number) = item.season_number else { + return Ok(None); + }; + Ok(Some(format!( + "tv:{show_external_id}:season:{season_number}" + ))) + } + "episode" => { + let Some(show_external_id) = show_tmdb_external_id_for_item(conn, item)? else { + return Ok(None); + }; + let (Some(season_number), Some(episode_number)) = + (item.season_number, item.episode_number) + else { + return Ok(None); + }; + Ok(Some(format!( + "tv:{show_external_id}:season:{season_number}:episode:{episode_number}" + ))) + } + _ => Ok(None), + } +} + +fn show_tmdb_external_id_for_item( + conn: &mut SqliteConnection, + item: &MediaItemSummary, +) -> Result, diesel::result::Error> { + let mut current_parent_id = item.parent_id; + while let Some(parent_id) = current_parent_id { + let Some(parent) = get_media_item_summary_without_metadata(conn, parent_id)? else { + return Ok(None); + }; + if parent.item_type == "show" { + let link = preferred_item_metadata_link_for_summary(conn, &parent)?; + if let Some(link) = link.filter(|link| { + link.provider_id == MetadataProviderId::Tmdb.as_storage_value() + && link.media_type.as_deref() == Some("tv") + }) { + return Ok(Some(link.external_id)); + } + return Ok(None); + } + current_parent_id = parent.parent_id; + } + + Ok(None) +} + +fn media_library_metadata_provider_order( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::media_libraries::dsl as media_libraries_dsl; + + let library = media_libraries_dsl::media_libraries + .filter(media_libraries_dsl::id.eq(library_id)) + .select(MediaLibrary::as_select()) + .first::(conn) + .optional()?; + + Ok(library + .map(media_library_settings_from_row) + .map(|settings| settings.metadata_providers) + .unwrap_or_else(|| vec![MetadataProviderId::Tmdb])) +} + +fn metadata_language_priority(preferred_languages: &[String]) -> HashMap { + let mut languages = preferred_languages + .iter() + .map(|language| normalize_locale_key(language)) + .filter(|language| !language.is_empty()) + .collect::>(); + if !languages + .iter() + .any(|language| language == DEFAULT_METADATA_LOCALE) + { + languages.push(DEFAULT_METADATA_LOCALE.to_string()); + } + + languages + .into_iter() + .enumerate() + .map(|(index, language)| (language, index)) + .collect() +} + +fn prioritized_metadata_links_for_item( + conn: &mut SqliteConnection, + item_id: i32, + library_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as item_metadata_links_dsl; + + let provider_order = media_library_metadata_provider_order(conn, library_id)?; + let provider_rank = provider_order + .iter() + .enumerate() + .map(|(index, provider)| (provider.as_storage_value().to_string(), index)) + .collect::>(); + let fallback_provider_rank = provider_rank.len(); + let language_rank = metadata_language_priority(preferred_languages); + let fallback_language_rank = language_rank.len(); + + let mut rows = item_metadata_links_dsl::item_metadata_links + .filter(item_metadata_links_dsl::media_item_id.eq(item_id)) + .filter(item_metadata_links_dsl::relation_kind.eq_any(["primary", "secondary"])) + .select(ItemMetadataLink::as_select()) + .load::(conn)? + .into_iter() + .filter(|link| provider_rank.contains_key(&link.provider_id)) + .collect::>(); + + rows.sort_by(|left, right| { + let left_provider_rank = provider_rank + .get(&left.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let right_provider_rank = provider_rank + .get(&right.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let left_language_rank = language_rank + .get(&normalize_locale_key(&left.locale_key)) + .copied() + .unwrap_or(fallback_language_rank); + let right_language_rank = language_rank + .get(&normalize_locale_key(&right.locale_key)) + .copied() + .unwrap_or(fallback_language_rank); + let left_relation_rank = if left.relation_kind == "primary" { 0 } else { 1 }; + let right_relation_rank = if right.relation_kind == "primary" { 0 } else { 1 }; + + left_provider_rank + .cmp(&right_provider_rank) + .then_with(|| left_relation_rank.cmp(&right_relation_rank)) + .then_with(|| left_language_rank.cmp(&right_language_rank)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| right.id.cmp(&left.id)) + }); + + let mut seen = HashSet::<(String, String)>::new(); + Ok(rows + .into_iter() + .filter(|link| seen.insert((link.provider_id.clone(), link.relation_kind.clone()))) + .collect()) +} + +fn prioritized_primary_metadata_links_for_item( + conn: &mut SqliteConnection, + item: &MediaItemSummary, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + Ok( + prioritized_metadata_links_for_item(conn, item.id, item.library_id, preferred_languages)? + .into_iter() + .filter(|link| link.relation_kind == "primary") + .collect(), + ) +} + +/// Return the preferred metadata link that can serve the requested artwork kind. +pub fn get_preferred_item_artwork_metadata_link_for_languages( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], + artwork_kind: ArtworkKind, +) -> Result, diesel::result::Error> { + let mut current_item_id = Some(item_id); + let mut visited = HashSet::new(); + while let Some(current_id) = current_item_id { + if !visited.insert(current_id) { + break; + } + let Some(item) = get_media_item_summary_without_metadata(conn, current_id)? else { + return Ok(None); + }; + if let Some(link) = prioritized_metadata_links_for_item( + conn, + current_id, + item.library_id, + preferred_languages, + )? + .into_iter() + .find(|link| metadata_link_has_artwork(link, artwork_kind)) + { + return Ok(Some(link)); + } + current_item_id = item.parent_id; + } + + Ok(None) +} + +fn metadata_link_has_artwork( + link: &ItemMetadataLink, + artwork_kind: ArtworkKind, +) -> bool { + match artwork_kind { + ArtworkKind::Poster => link.cached_artwork_path.is_some() || link.artwork_url.is_some(), + ArtworkKind::Backdrop => link.cached_backdrop_path.is_some() || link.backdrop_url.is_some(), + ArtworkKind::Logo => link.cached_logo_path.is_some() || link.logo_url.is_some(), + } +} + +fn to_media_item_detail( + item: MediaItem, + backing_file: Option<&CatalogMediaFile>, +) -> MediaItemDetail { + let relative_path = item.relative_path.unwrap_or_default(); + + MediaItemDetail { + id: item.id, + library_id: item.library_id, + parent_id: item.parent_id, + item_type: item.item_type.clone(), + display_title: item.display_title, + relative_path, + file_size: item.file_size, + modified_at: item.modified_at, + media_kind: item + .media_kind + .unwrap_or_else(|| default_media_kind_for_item_type(&item.item_type).to_string()), + playable: item.playable, + child_count: item.child_count, + available_season_count: None, + season_number: item.season_number, + episode_number: item.episode_number, + container: backing_file.and_then(|file| file.container.clone()), + duration_ms: item.duration_ms, + bit_rate: backing_file.and_then(|file| file.bit_rate), + width: backing_file.and_then(|file| file.width), + height: backing_file.and_then(|file| file.height), + video_codec: backing_file.and_then(|file| file.video_codec.clone()), + audio_codec: backing_file.and_then(|file| file.audio_codec.clone()), + metadata_json: backing_file.and_then(|file| file.metadata_json.clone()), + metadata_updated_at: backing_file.and_then(|file| file.metadata_updated_at), + poster_url: None, + backdrop_url: None, + theme_song_url: None, + tagline: None, + overview: None, + genres: Vec::new(), + release_year: None, + logo_url: None, + rating: None, + content_rating: None, + linked_media_type: None, + has_metadata: false, + metadata_refresh_state: None, + metadata_refresh_error: None, + artwork_updated_at: None, + trailer_title: None, + trailer_url: None, + extras: Vec::new(), + audio_tracks: Vec::new(), + subtitle_tracks: Vec::new(), + hierarchy: Vec::new(), + children: Vec::new(), + playback_target: None, + restart_playback_target: None, + playback_position_ms: None, + playback_duration_ms: None, + playback_completed: false, + watch_count: 0, + last_watched_at: None, + missing_since: item.missing_since, + } +} + +fn default_media_kind_for_item_type(item_type: &str) -> &'static str { + match item_type { + "track" => "audio", + "photo" => "image", + "book" => "book", + _ => "video", + } +} + +fn load_backing_media_file( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + load_catalog_file_for_item(conn, item_id) +} + +fn load_media_item_hierarchy( + conn: &mut SqliteConnection, + item: &MediaItem, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let mut hierarchy = Vec::new(); + let mut next_parent_id = item.parent_id; + + while let Some(parent_id) = next_parent_id { + let Some(parent) = media_items_dsl::media_items + .filter(media_items_dsl::id.eq(parent_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .select(MediaItem::as_select()) + .first(conn) + .optional()? + else { + break; + }; + + next_parent_id = parent.parent_id; + hierarchy.push(media_item_summary_with_preferred_languages( + conn, + parent, + preferred_languages, + )?); + } + + hierarchy.reverse(); + Ok(hierarchy) +} + +/// Return direct child summaries for one browser-facing media item. +pub fn list_media_item_children( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + list_media_item_children_with_preferred_languages(conn, item_id, &[]) +} + +/// Return direct child summaries for one browser-facing media item and preferred languages. +pub fn list_media_item_children_with_preferred_languages( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + + let rows = media_items_dsl::media_items + .filter(media_items_dsl::parent_id.eq(item_id)) + .filter(media_items_dsl::deleted_at.is_null()) + .order(( + media_items_dsl::season_number.asc(), + media_items_dsl::episode_number.asc(), + media_items_dsl::display_title.asc(), + media_items_dsl::relative_path.asc(), + )) + .select(MediaItem::as_select()) + .load::(conn)?; + + let metadata_links = preferred_metadata_links_by_item_id( + conn, + &rows.iter().map(|row| row.id).collect::>(), + preferred_languages, + )?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let mut summary = to_media_item_summary(row); + let summary_id = summary.id; + apply_primary_metadata_link(&mut summary, metadata_links.get(&summary_id)); + items.push(summary); + } + + Ok(items) +} + +fn detect_binary(binary: &str) -> BinaryCapability { + let output = Command::new(binary).arg("-version").output(); + + match output { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()); + + BinaryCapability { + configured_path: binary.to_string(), + available: true, + version, + error: None, + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let error = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("Process exited with status {}", output.status) + }; + + BinaryCapability { + configured_path: binary.to_string(), + available: false, + version: None, + error: Some(error), + } + } + Err(error) => BinaryCapability { + configured_path: binary.to_string(), + available: false, + version: None, + error: Some(error.to_string()), + }, + } +} + +fn parse_library_storage_paths(value: &str) -> Vec { + let trimmed = value.trim(); + if trimmed.starts_with('[') { + serde_json::from_str::>(trimmed).unwrap_or_default() + } else if trimmed.is_empty() { + Vec::new() + } else { + vec![trimmed.to_string()] + } +} + +#[derive(Debug, Default)] +struct ResolvedItemAssets { + poster_path: Option, + backdrop_path: Option, + theme_song_path: Option, + subtitle_paths: Vec, +} + +fn discover_item_assets( + item_id: i32, + source_path: &Path, + data_dir: &str, +) -> ResolvedItemAssets { + let managed_dir = managed_item_asset_dir(data_dir, item_id); + let source_dir = source_path.parent().unwrap_or_else(|| Path::new("")); + let source_stem = source_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + let poster_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "poster.jpg", + "poster.jpeg", + "poster.png", + "poster.webp", + "folder.jpg", + "folder.jpeg", + "folder.png", + "cover.jpg", + "cover.png", + ], + &[ + source_stem.clone(), + format!("{}-poster", source_stem), + ], + &["jpg", "jpeg", "png", "webp"], + ); + let backdrop_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "backdrop.jpg", + "backdrop.jpeg", + "backdrop.png", + "fanart.jpg", + "fanart.jpeg", + "fanart.png", + "background.jpg", + "background.png", + ], + &[ + format!("{}-backdrop", source_stem), + format!("{}-fanart", source_stem), + ], + &["jpg", "jpeg", "png", "webp"], + ); + let theme_song_path = find_first_existing_asset( + &[ + managed_dir.as_path(), + source_dir, + ], + &[ + "theme.mp3", + "theme.flac", + "theme.m4a", + "theme.ogg", + "theme.opus", + "theme.wav", + ], + &[], + &[], + ); + + let subtitle_paths = collect_subtitle_assets( + &[ + managed_dir.as_path(), + source_dir, + ], + &source_stem, + ); + + ResolvedItemAssets { + poster_path, + backdrop_path, + theme_song_path, + subtitle_paths, + } +} + +fn managed_item_asset_dir( + data_dir: &str, + item_id: i32, +) -> PathBuf { + let item_hex = format!("{:08x}", item_id.max(0)); + let shard = &item_hex[0..2]; + Path::new(data_dir) + .join("item_assets") + .join(shard) + .join(item_hex) +} + +fn find_first_existing_asset( + directories: &[&Path], + fixed_names: &[&str], + stem_names: &[String], + extensions: &[&str], +) -> Option { + for directory in directories { + for fixed_name in fixed_names { + let candidate = directory.join(fixed_name); + if candidate.is_file() { + return Some(candidate); + } + } + + for stem_name in stem_names { + for extension in extensions { + let candidate = directory.join(format!("{}.{}", stem_name, extension)); + if candidate.is_file() { + return Some(candidate); + } + } + } + } + + None +} + +fn collect_subtitle_assets( + directories: &[&Path], + source_stem: &str, +) -> Vec { + let mut subtitle_paths = Vec::new(); + let mut seen = HashSet::new(); + + for directory in directories { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_subtitle_extension(&path) { + continue; + } + + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + if stem != source_stem && !stem.starts_with(&format!("{}.", source_stem)) { + continue; + } + + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + subtitle_paths.push(path); + } + } + } + + subtitle_paths.sort(); + subtitle_paths +} + +fn subtitle_label_from_path( + source_path: &Path, + subtitle_path: &Path, +) -> String { + let source_stem = source_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + let subtitle_stem = subtitle_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let normalized_subtitle_stem = subtitle_stem.to_ascii_lowercase(); + + if let Some(suffix) = normalized_subtitle_stem.strip_prefix(&format!("{}.", source_stem)) { + if !suffix.trim().is_empty() { + return suffix.to_ascii_uppercase(); + } + } + + subtitle_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| "Subtitle".into()) +} + +fn is_subtitle_extension(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .as_deref(), + Some("srt" | "vtt" | "ass" | "ssa" | "sub") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary( + id: i32, + parent_id: Option, + item_type: &str, + title: &str, + child_count: i32, + duration_ms: Option, + modified_at: Option, + ) -> MediaItemSummary { + MediaItemSummary { + id, + library_id: 1, + parent_id, + item_type: item_type.to_string(), + display_title: title.to_string(), + display_subtitle: None, + artwork_item_id: None, + relative_path: title.to_string(), + media_kind: "video".to_string(), + playable: child_count == 0, + child_count, + available_season_count: None, + season_number: None, + episode_number: None, + duration_ms, + width: None, + height: None, + genres: Vec::new(), + overview: None, + backdrop_url: None, + logo_url: None, + has_metadata: false, + metadata_refresh_state: None, + metadata_refresh_error: None, + artwork_updated_at: None, + modified_at, + playback_position_ms: None, + playback_duration_ms: None, + playback_completed: false, + watch_count: 0, + last_watched_at: None, + missing_since: None, + } + } + + #[test] + fn recommended_collapses_show_children_to_single_show() { + let show = summary( + 10, + None, + "show", + "Example Show", + 1, + Some(7_200_000), + Some(10), + ); + let season = summary( + 11, + Some(10), + "season", + "Season 1", + 1, + Some(7_200_000), + Some(20), + ); + let episode = summary( + 12, + Some(11), + "episode", + "Episode 1", + 0, + Some(3_600_000), + Some(30), + ); + let movie = summary( + 20, + None, + "movie", + "Example Movie", + 0, + Some(5_400_000), + Some(40), + ); + + let recommended = sort_recommended(&[season, episode, show, movie], &[]); + + assert_eq!( + recommended + .iter() + .map(|item| item.item_type.as_str()) + .collect::>(), + vec!["show", "movie"] + ); + assert_eq!( + recommended + .iter() + .map(|item| item.display_title.as_str()) + .collect::>(), + vec![ + "Example Show", + "Example Movie" + ] + ); + } + + #[test] + fn recommended_excludes_show_when_child_is_continue_watching() { + let show = summary( + 10, + None, + "show", + "Example Show", + 1, + Some(7_200_000), + Some(10), + ); + let season = summary( + 11, + Some(10), + "season", + "Season 1", + 1, + Some(7_200_000), + Some(20), + ); + let episode = summary( + 12, + Some(11), + "episode", + "Episode 1", + 0, + Some(3_600_000), + Some(30), + ); + let movie = summary( + 20, + None, + "movie", + "Example Movie", + 0, + Some(5_400_000), + Some(40), + ); + + let recommended = sort_recommended( + &[ + show, + season, + episode.clone(), + movie, + ], + std::slice::from_ref(&episode), + ); + + assert_eq!(recommended.len(), 1); + assert_eq!(recommended[0].display_title, "Example Movie"); + } + + #[test] + fn home_shelf_sorters_do_not_cap_at_twelve_items() { + let items = (0..14) + .map(|index| { + summary( + index + 1, + None, + "movie", + &format!("Movie {index:02}"), + 0, + Some(1_000 + i64::from(index)), + Some(i64::from(index)), + ) + }) + .collect::>(); + + assert_eq!(sort_recently_added(&items).len(), 14); + assert_eq!(sort_recommended(&items, &[]).len(), 14); + } +} diff --git a/crates/server/src/metadata/mod.rs b/crates/server/src/metadata/mod.rs new file mode 100644 index 00000000..bd211040 --- /dev/null +++ b/crates/server/src/metadata/mod.rs @@ -0,0 +1,4768 @@ +//! Metadata-provider registry and persistence helpers. + +// standard imports +use std::collections::hash_map::DefaultHasher; +use std::collections::{ + HashMap, + HashSet, +}; +use std::fs; +use std::hash::{ + Hash, + Hasher, +}; +use std::path::{ + Path, + PathBuf, +}; +use std::time::Duration; + +// lib imports +use diesel::{ + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + OptionalExtension, + QueryDsl, + RunQueryDsl, + SelectableHelper, + SqliteConnection, +}; +use once_cell::sync::Lazy; +use regex::Regex; +use schemars::JsonSchema; +use serde::{ + Deserialize, + Serialize, +}; +use sha2::{ + Digest, + Sha256, +}; +use strsim::normalized_levenshtein; + +mod providers; +pub use providers::{ + MetadataProvider, + MetadataRegistry, +}; + +// local imports +use crate::config::{ + MediaLibraryKind, + MetadataProviderId, + MetadataProviderSettings, + MetadataSettings, + metadata_provider_api_key_configured, + resolve_metadata_provider_api_key, +}; +use crate::db::configure_sqlite_connection; +use crate::db::models::{ + ExternalMedia, + ItemMetadataLink, + MediaItem, + MetadataCollection, + MetadataCollectionItem, + MetadataExtra, + MetadataPerson, + MetadataPersonCredit, + NewExternalMedia, + NewItemMetadataExternalId, + NewItemMetadataLink, + NewItemMetadataPerson, + NewMetadataCollection, + NewMetadataCollectionItem, + NewMetadataExtra, + NewMetadataPerson, + NewMetadataPersonCredit, + NewMetadataPersonExternalId, +}; +use crate::utils::current_timestamp; + +const DEFAULT_METADATA_REFRESH_INTERVAL_SECONDS: i64 = 30 * 24 * 60 * 60; +const METADATA_RESPONSE_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60; +/// Default Koko metadata locale used when no user preference is available. +pub const DEFAULT_METADATA_LOCALE: &str = "en-US"; + +/// High-level descriptor for a metadata provider. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderDescriptor { + /// Stable identifier for the provider. + pub id: MetadataProviderId, + /// Human-friendly provider name. + pub display_name: String, + /// Short description of the provider's purpose. + pub description: String, + /// Supported media-library kinds. + pub supported_kinds: Vec, + /// Whether an API key is required. + pub requires_api_key: bool, + /// Whether the provider is implemented in the current build. + pub implemented: bool, + /// Whether this provider can be selected as primary metadata or extends another provider. + pub role: MetadataProviderRole, + /// Primary providers this secondary provider can extend. + pub extends_provider_ids: Vec, + /// Provider attribution text for UI display. + pub attribution_text: String, + /// Provider attribution link. + pub attribution_url: String, + /// Provider logo suitable for light backgrounds. + pub logo_light_url: Option, + /// Provider logo suitable for dark backgrounds. + pub logo_dark_url: Option, +} + +/// How a metadata provider participates in metadata acquisition. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MetadataProviderRole { + /// Provider can be the primary source of item metadata. + Primary, + /// Provider enriches metadata from one or more primary providers. + Secondary, +} + +/// Runtime status for a metadata provider after applying user settings. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataProviderStatus { + /// Stable identifier for the provider. + pub id: MetadataProviderId, + /// Human-friendly provider name. + pub display_name: String, + /// Short description of the provider's purpose. + pub description: String, + /// Supported media-library kinds. + pub supported_kinds: Vec, + /// Whether an API key is required. + pub requires_api_key: bool, + /// Whether the provider is implemented in the current build. + pub implemented: bool, + /// Whether this provider can be selected as primary metadata or extends another provider. + pub role: MetadataProviderRole, + /// Primary providers this secondary provider can extend. + pub extends_provider_ids: Vec, + /// Whether the provider is enabled in configuration. + pub enabled: bool, + /// Whether the provider has enough configuration to be used. + pub configured: bool, + /// Configured language preference for the provider. + pub language: String, + /// Provider attribution text for UI display. + pub attribution_text: String, + /// Provider attribution link. + pub attribution_url: String, + /// Provider logo suitable for light backgrounds. + pub logo_light_url: Option, + /// Provider logo suitable for dark backgrounds. + pub logo_dark_url: Option, +} + +/// Stored metadata match summary for one media item. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +pub struct ItemMetadataSummary { + /// Stable row identifier for the metadata link. + pub id: i32, + /// Provider identifier for the linked metadata. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Linked title, if available. + pub title: Option, + /// Linked overview, if available. + pub overview: Option, + /// Poster or artwork URL, if available. + pub artwork_url: Option, + /// Backdrop artwork URL, if available. + pub backdrop_url: Option, + /// Release year, if available. + pub release_year: Option, + /// Provider-specific media type such as `movie` or `tv`. + pub media_type: Option, + /// Link relation such as `primary` or `secondary`. + pub relation_kind: String, + /// Current match state. + pub match_state: String, + /// Provider-supplied title logo URL, when available. + pub logo_url: Option, + /// Cached title logo path, when available. + pub cached_logo_path: Option, + /// Provider genre labels stored directly for querying and UI use. + pub genres: Vec, + /// People credited by the provider, including cast and crew. + pub people: Vec, + /// Provider-supplied user/community rating, when available. + pub rating: Option, + /// Provider-supplied content rating such as PG-13 or TV-MA, when available. + pub content_rating: Option, + /// Human-friendly trailer title, when available. + pub trailer_title: Option, + /// Trailer URL, when available. + pub trailer_url: Option, + /// Theme-song URL, when supplied by provider metadata. + pub theme_song_url: Option, + /// Koko locale key for this stored metadata row. + pub locale_key: String, + /// Provider-specific locale key used to fetch this row. + pub provider_locale_key: Option, + /// Cached poster path, when available. + pub cached_artwork_path: Option, + /// Cached backdrop path, when available. + pub cached_backdrop_path: Option, + /// Current refresh state such as fresh, pending, or error. + pub refresh_state: String, + /// Last successful refresh timestamp as Unix seconds, if available. + pub last_refreshed_at: Option, + /// Scheduled next refresh time as Unix seconds, if available. + pub next_refresh_at: Option, + /// Last refresh error, when available. + pub refresh_error: Option, + /// Last update timestamp as Unix seconds, if available. + pub updated_at: Option, +} + +/// Provider-neutral person credit linked to stored metadata. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct ItemMetadataPersonSummary { + /// Stable row identifier for the stored person credit. + pub id: i32, + /// Stable database identifier for this normalized person. + pub person_id: i32, + /// Provider-side person identifier, when available. + pub external_id: Option, + /// Koko locale key for this localized person row. + pub locale_key: String, + /// Display name. + pub name: String, + /// Job or credit role such as Actor, Director, or Writer. + pub role: Option, + /// High-level department such as Cast, Directing, or Writing. + pub department: Option, + /// Character name for acting credits. + pub character_name: Option, + /// Provider person page URL, when available. + pub profile_url: Option, + /// Provider image URL, when available. + pub image_url: Option, + /// Cached local image path, when available. + pub cached_image_path: Option, + /// Provider/source order for stable presentation. + pub sort_order: i32, +} + +/// Normalized provider-scoped person stored in Koko. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataPersonSummary { + /// Stable person identifier. + pub id: i32, + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side person identifier, when available. + pub external_id: Option, + /// Koko locale key for this localized person row. + pub locale_key: String, + /// Display name. + pub name: String, + /// Titles this person is known for. + pub known_for: Vec, + /// Provider biography or description. + pub biography: Option, + /// Provider-neutral gender label, when known. + pub gender: Option, + /// Birth date as provider-supplied ISO date, when known. + pub birthday: Option, + /// Death date as provider-supplied ISO date, when known. + pub deathday: Option, + /// Birth place, when known. + pub birth_place: Option, + /// Provider person page URL, when available. + pub profile_url: Option, + /// Provider image URL, when available. + pub image_url: Option, + /// Cached local image path, when available. + pub cached_image_path: Option, + /// Last update timestamp as Unix seconds, if available. + pub updated_at: Option, +} + +/// Provider person row that can be enriched after item metadata has been refreshed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataPersonEnrichmentTarget { + /// Stable person row identifier. + pub id: i32, + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side person identifier. + pub external_id: String, + /// Koko locale key for this localized person row. + pub locale_key: String, + /// Current display name. + pub name: String, +} + +/// One credit connecting a normalized person to an item metadata link. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataPersonCreditSummary { + /// Stable credit identifier. + pub id: i32, + /// Linked metadata row identifier. + pub metadata_link_id: i32, + /// Media item represented by the metadata row. + pub media_item_id: i32, + /// Job or credit role such as Actor, Director, or Writer. + pub role: Option, + /// High-level department such as Cast, Directing, or Writing. + pub department: Option, + /// Character name for acting credits. + pub character_name: Option, + /// Provider/source order for stable presentation. + pub sort_order: i32, +} + +/// Search result returned by a metadata provider. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +pub struct MetadataSearchResult { + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type. + pub media_type: String, + /// Candidate title. + pub title: String, + /// Candidate overview, if available. + pub overview: Option, + /// Candidate poster URL, if available. + pub artwork_url: Option, + /// Candidate backdrop URL, if available. + pub backdrop_url: Option, + /// Candidate release year, if available. + pub release_year: Option, + /// Match score from 0.0 to 1.0, when Koko can compute one. + pub score: Option, +} + +/// Collection summary aggregated across linked metadata rows. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct MetadataCollectionSummary { + /// Stable Koko collection identifier. + pub id: String, + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Collection name. + pub name: String, + /// Collection overview when available. + pub overview: Option, + /// Collection poster or artwork URL when available. + pub artwork_url: Option, + /// Collection backdrop URL when available. + pub backdrop_url: Option, + /// Theme-song URL when available. + pub theme_song_url: Option, + /// Root media item identifiers that belong to the collection. + pub item_ids: Vec, + /// Number of unique root items in the collection. + pub item_count: usize, +} + +/// Koko's provider-neutral metadata item kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataItemKind { + /// A feature-length movie. + Movie, + /// A television or episodic series. + Show, + /// A season within a show. + Season, + /// An episode within a season. + Episode, + /// A provider collection or list that groups items. + Collection, + /// A person, actor, creator, or crew member. + Person, + /// A production or distribution company. + Company, + /// A provider award record. + Award, + /// A generic metadata item when no narrower Koko kind applies. + Item, +} + +impl MetadataItemKind { + fn asset_directory(self) -> &'static str { + match self { + MetadataItemKind::Movie => "movies", + MetadataItemKind::Show => "shows", + MetadataItemKind::Season => "seasons", + MetadataItemKind::Episode => "episodes", + MetadataItemKind::Collection => "collections", + MetadataItemKind::Person => "people", + MetadataItemKind::Company => "companies", + MetadataItemKind::Award => "awards", + MetadataItemKind::Item => "items", + } + } +} + +/// Stored metadata snapshot fetched from a provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredMetadataSnapshot { + /// Provider identifier. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type. + pub media_type: Option, + /// Canonical title. + pub title: Option, + /// Canonical overview. + pub overview: Option, + /// Poster URL. + pub artwork_url: Option, + /// Backdrop URL. + pub backdrop_url: Option, + /// Release year. + pub release_year: Option, + /// Koko locale key for this snapshot. + pub locale_key: String, + /// Provider-specific locale key used to fetch this snapshot. + pub provider_locale_key: Option, + /// Raw provider payload. + pub provider_payload_json: Option, +} + +/// Options for fetching provider metadata snapshots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataSnapshotFetchOptions { + /// Whether provider fetches should make extra calls for full person records. + pub include_person_details: bool, +} + +impl MetadataSnapshotFetchOptions { + /// Fetch the full snapshot, including person-detail enrichment. + pub const FULL: Self = Self { + include_person_details: true, + }; + /// Fetch item presentation metadata without expensive person-detail enrichment. + pub const WITHOUT_PERSON_DETAILS: Self = Self { + include_person_details: false, + }; + + fn cache_key_fragment(self) -> &'static str { + if self.include_person_details { "person-details:1" } else { "person-details:0" } + } +} + +impl Default for MetadataSnapshotFetchOptions { + fn default() -> Self { + Self::FULL + } +} + +/// Inputs for fetching a provider episode metadata snapshot. +pub struct ProviderEpisodeMetadataSnapshotFetch<'a> { + /// Provider show identifier that owns the episode. + pub show_external_id: &'a str, + /// Season number for the requested episode. + pub season_number: i32, + /// Episode number within the requested season. + pub episode_number: i32, + /// Provider episode identifier when the source has one. + pub episode_external_id: Option<&'a str>, + /// Koko locale key requested for the snapshot. + pub locale_key: &'a str, + /// Fetch behavior options. + pub options: MetadataSnapshotFetchOptions, +} + +/// Provider-normalized metadata fields that are persisted into Koko tables. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ProviderMetadataDetails { + /// External identifiers normalized into Koko's database. + pub external_ids: Vec, + /// Tagline or short promotional line. + pub tagline: Option, + /// Provider-supplied title logo URL. + pub logo_url: Option, + /// Provider genre labels. + pub genres: Vec, + /// Provider-supplied user/community rating. + pub rating: Option, + /// Provider-supplied content rating such as PG-13 or TV-MA. + pub content_rating: Option, + /// Human-friendly trailer title. + pub trailer_title: Option, + /// Trailer URL. + pub trailer_url: Option, + /// Theme-song URL. + pub theme_song_url: Option, + /// Provider-supplied external media extras. + pub extras: Vec, + /// Collections this metadata item belongs to. + pub collections: Vec, + /// People credited by the provider. + pub people: Vec, +} + +/// Provider-normalized external media extra ready for persistence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderMetadataExtra { + /// Koko extra type such as `trailer`, `clip`, or `theme_song`. + pub extra_type: String, + /// Human-friendly extra title. + pub title: Option, + /// External media URL. + pub url: String, + /// External media duration in seconds, when the provider exposes it. + pub duration_seconds: Option, + /// Thumbnail URL, when available. + pub thumbnail_url: Option, + /// Provider/source order for stable presentation. + pub sort_order: i32, +} + +/// TrailerDB/TMDb-style trailer video. +pub const METADATA_EXTRA_TYPE_TRAILER: &str = "trailer"; +/// Short trailer teaser. +pub const METADATA_EXTRA_TYPE_TEASER: &str = "teaser"; +/// Scene clip or preview. +pub const METADATA_EXTRA_TYPE_CLIP: &str = "clip"; +/// Behind-the-scenes or making-of video. +pub const METADATA_EXTRA_TYPE_BEHIND_THE_SCENES: &str = "behind_the_scenes"; +/// Blooper reel. +pub const METADATA_EXTRA_TYPE_BLOOPERS: &str = "bloopers"; +/// Featurette or interview-style promotional video. +pub const METADATA_EXTRA_TYPE_FEATURETTE: &str = "featurette"; +/// Opening credits sequence. +pub const METADATA_EXTRA_TYPE_OPENING_CREDITS: &str = "opening_credits"; +/// Episode or season recap. +pub const METADATA_EXTRA_TYPE_RECAP: &str = "recap"; +/// Theme-song video or audio. +pub const METADATA_EXTRA_TYPE_THEME_SONG: &str = "theme_song"; + +/// Extra types currently understood by Koko. +pub const SUPPORTED_METADATA_EXTRA_TYPES: &[&str] = &[ + METADATA_EXTRA_TYPE_TRAILER, + METADATA_EXTRA_TYPE_TEASER, + METADATA_EXTRA_TYPE_CLIP, + METADATA_EXTRA_TYPE_BEHIND_THE_SCENES, + METADATA_EXTRA_TYPE_BLOOPERS, + METADATA_EXTRA_TYPE_FEATURETTE, + METADATA_EXTRA_TYPE_OPENING_CREDITS, + METADATA_EXTRA_TYPE_RECAP, + METADATA_EXTRA_TYPE_THEME_SONG, +]; + +/// Provider-normalized external identifier for cross-provider lookups. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderExternalId { + /// Stable source/database key such as `imdb`, `tmdb`, or `thetvdb`. + pub source: String, + /// External identifier within the source/database. + pub external_id: String, +} + +pub(crate) fn normalize_external_id_source(source: &str) -> Option { + let source = source.trim().to_ascii_lowercase(); + let source = source + .strip_prefix("https://") + .or_else(|| source.strip_prefix("http://")) + .unwrap_or(&source); + let source = source.strip_prefix("www.").unwrap_or(source); + let source = source.trim_end_matches('/'); + let source = match source { + "imdb.com" | "imdb_id" => "imdb", + "themoviedb.org" | "themoviedb.com" | "themoviedb" | "tmdb.com" | "tmdb_id" => "tmdb", + "thetvdb.com" | "tvdb.com" | "tvdb" | "tvdb_id" => "thetvdb", + "facebook.com" | "facebook_id" => "facebook", + "instagram.com" | "instagram_id" => "instagram", + "twitter.com" | "x.com" | "twitter_id" => "twitter", + "wikidata.org" | "wikidata_id" => "wikidata", + "youtube.com" | "youtube_id" => "youtube", + other => other, + }; + + let mut normalized = String::new(); + let mut previous_was_separator = false; + for character in source.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character); + previous_was_separator = false; + } else if !previous_was_separator { + normalized.push('_'); + previous_was_separator = true; + } + } + let normalized = normalized.trim_matches('_').to_string(); + (!normalized.is_empty()).then_some(normalized) +} + +/// Provider-normalized collection metadata ready for persistence. +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderMetadataCollection { + /// Provider-side collection identifier. + pub external_id: String, + /// Collection name. + pub name: Option, + /// Collection overview. + pub overview: Option, + /// Collection poster or artwork URL. + pub artwork_url: Option, + /// Collection backdrop URL. + pub backdrop_url: Option, + /// Collection theme-song URL. + pub theme_song_url: Option, +} + +/// Provider-normalized person credit ready for persistence. +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderMetadataPerson { + /// Provider-side person identifier. + pub external_id: Option, + /// External identifiers normalized into Koko's database for cross-provider person matching. + pub external_ids: Vec, + /// Display name. + pub name: String, + /// Titles this person is known for. + pub known_for: Vec, + /// Provider biography. + pub biography: Option, + /// Provider-neutral gender label, when known. + pub gender: Option, + /// Birth date as provider-supplied ISO date, when known. + pub birthday: Option, + /// Death date as provider-supplied ISO date, when known. + pub deathday: Option, + /// Birth place, when known. + pub birth_place: Option, + /// Job or credit role. + pub role: Option, + /// High-level department. + pub department: Option, + /// Character name for acting credits. + pub character_name: Option, + /// Provider person page URL. + pub profile_url: Option, + /// Provider image URL. + pub image_url: Option, + /// Cached local image path. + pub cached_image_path: Option, + /// Provider/source order for stable presentation. + pub sort_order: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MetadataSnapshotCacheEntry { + created_at: i64, + snapshot: StoredMetadataSnapshot, +} + +/// Presentation fields derived from one stored metadata link. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LinkedMetadataPresentation { + /// Tagline or short promotional line. + pub tagline: Option, + /// Long-form description or overview. + pub overview: Option, + /// Genre labels from the provider payload. + pub genres: Vec, + /// Release year, when known. + pub release_year: Option, + /// Provider media type such as movie or tv. + pub media_type: Option, + /// Whether poster artwork is available either locally or remotely. + pub poster_available: bool, + /// Whether backdrop artwork is available either locally or remotely. + pub backdrop_available: bool, + /// Provider-supplied title logo URL, when available. + pub logo_url: Option, + /// Provider-supplied user/community rating, when available. + pub rating: Option, + /// Provider-supplied content rating such as PG-13 or TV-MA, when available. + pub content_rating: Option, + /// Human-friendly trailer title, when available. + pub trailer_title: Option, + /// Trailer URL, when available. + pub trailer_url: Option, + /// Theme-song URL, when available. + pub theme_song_url: Option, +} + +/// Presentation-ready external media extra attached to an item metadata link. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkedMetadataExtra { + /// Koko extra type such as `trailer`, `clip`, or `theme_song`. + pub extra_type: String, + /// Human-friendly extra title, when available. + pub title: Option, + /// External media URL. + pub url: String, + /// External media duration in seconds, when known. + pub duration_seconds: Option, + /// External thumbnail URL, when available. + pub thumbnail_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedMovieName { + title: String, + year: Option, + provider_ids: HashMap, +} + +impl ParsedMovieName { + fn provider_id( + &self, + provider: &str, + ) -> Option<&str> { + self.provider_ids + .get(&provider.trim().to_ascii_lowercase()) + .map(String::as_str) + } +} + +static BRACED_TAG_REGEX: Lazy = Lazy::new(|| Regex::new(r"[\{\[]([^\}\]]*)[\}\]]").unwrap()); +static YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\b(19\d{2}|20\d{2}|21\d{2})\b").unwrap()); +static PARENTHETICAL_YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\(\[]\s*(19\d{2}|20\d{2}|21\d{2})\s*[\)\]]").unwrap()); +static SPLIT_SUFFIX_REGEX: Lazy = + Lazy::new(|| Regex::new(r"(?i)\s*[-–]\s*(cd|disc|disk|dvd|part|pt)\s*\d+\s*$").unwrap()); +static TITLE_COLON_DASH_REGEX: Lazy = Lazy::new(|| Regex::new(r"\s*-\s+").unwrap()); +static YOUTUBE_VIDEO_ID_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{11}$").unwrap()); +static NOISE_TOKEN_REGEX: Lazy = Lazy::new(|| { + Regex::new(concat!( + r"(?i)\b(2160p|1080p|720p|480p|x264|x265|h264|h265|hevc|hdr|dv", + r"|webrip|web[- ]dl|bluray|brrip|dvdrip|remux|proper|repack|extended", + r"|unrated|criterion|aac|dts|truehd|atmos)\b", + )) + .unwrap() +}); + +/// Extract a YouTube video id from a raw id, watch URL, short URL, embed URL, shorts URL, or live +/// URL. +pub fn extract_youtube_video_id(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if YOUTUBE_VIDEO_ID_REGEX.is_match(trimmed) { + return Some(trimmed.to_string()); + } + + let parse_target = if trimmed.starts_with("//youtube.com/") + || trimmed.starts_with("//www.youtube.com/") + || trimmed.starts_with("//youtu.be/") + { + format!("https:{trimmed}") + } else if trimmed.starts_with("youtube.com/") + || trimmed.starts_with("www.youtube.com/") + || trimmed.starts_with("youtu.be/") + { + format!("https://{trimmed}") + } else { + trimmed.to_string() + }; + let parsed = reqwest::Url::parse(&parse_target).ok()?; + let host = parsed + .host_str()? + .trim_start_matches("www.") + .to_ascii_lowercase(); + if host == "youtu.be" { + return parsed + .path_segments() + .and_then(|mut segments| segments.next()) + .map(str::trim) + .filter(|segment| YOUTUBE_VIDEO_ID_REGEX.is_match(segment)) + .map(ToOwned::to_owned); + } + + let is_youtube_host = host == "youtube.com" + || host.ends_with(".youtube.com") + || host == "youtube-nocookie.com" + || host.ends_with(".youtube-nocookie.com"); + if !is_youtube_host { + return None; + } + + if parsed.path() == "/watch" { + return parsed + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.trim().to_string()) + .filter(|video_id| YOUTUBE_VIDEO_ID_REGEX.is_match(video_id)); + } + + let mut segments = parsed.path_segments()?; + match segments.next()? { + "embed" | "shorts" | "live" => segments + .next() + .map(str::trim) + .filter(|segment| YOUTUBE_VIDEO_ID_REGEX.is_match(segment)) + .map(ToOwned::to_owned), + _ => None, + } +} + +/// Return a canonical YouTube watch URL for a raw YouTube id or URL. +pub fn youtube_watch_url(value: &str) -> Option { + extract_youtube_video_id(value) + .map(|video_id| format!("https://www.youtube.com/watch?v={video_id}")) +} + +/// Return a browser-embeddable YouTube URL for a raw YouTube id or URL. +pub fn youtube_embed_url( + value: &str, + autoplay: bool, +) -> Option { + extract_youtube_video_id(value).map(|video_id| { + format!( + "https://www.youtube.com/embed/{video_id}?autoplay={}&rel=0", + if autoplay { 1 } else { 0 } + ) + }) +} + +/// Normalize a provider extra type into Koko's storage vocabulary. +pub fn normalize_metadata_extra_type(value: &str) -> Option { + let mut normalized = String::new(); + let mut previous_was_separator = false; + for character in value.trim().chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_lowercase()); + previous_was_separator = false; + } else if !previous_was_separator { + normalized.push('_'); + previous_was_separator = true; + } + } + let normalized = normalized.trim_matches('_'); + if normalized.is_empty() { + return None; + } + + let canonical = match normalized { + "trailer" | "trailers" => METADATA_EXTRA_TYPE_TRAILER, + "teaser" | "teasers" => METADATA_EXTRA_TYPE_TEASER, + "clip" | "clips" | "scene" | "scenes" => METADATA_EXTRA_TYPE_CLIP, + "behind_the_scenes" | "behind_the_scene" | "behindthescenes" | "behindthescene" | "bts" + | "making_of" | "makingof" => METADATA_EXTRA_TYPE_BEHIND_THE_SCENES, + "blooper" | "bloopers" => METADATA_EXTRA_TYPE_BLOOPERS, + "featurette" | "featurettes" => METADATA_EXTRA_TYPE_FEATURETTE, + "opening_credit" | "opening_credits" | "openingcredit" | "openingcredits" => { + METADATA_EXTRA_TYPE_OPENING_CREDITS + } + "recap" | "recaps" => METADATA_EXTRA_TYPE_RECAP, + "theme" | "theme_song" | "theme_songs" | "themesong" | "themesongs" => { + METADATA_EXTRA_TYPE_THEME_SONG + } + _ => normalized, + }; + + Some(canonical.to_string()) +} + +/// Normalize a Koko locale key into the canonical storage format. +pub fn normalize_locale_key(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return DEFAULT_METADATA_LOCALE.to_string(); + } + + let mut parts = trimmed.split(['-', '_']); + let language = parts.next().unwrap_or("en").to_ascii_lowercase(); + if let Some(region) = parts.next().filter(|region| !region.is_empty()) { + format!("{}-{}", language, region.to_ascii_uppercase()) + } else if language == "en" { + DEFAULT_METADATA_LOCALE.to_string() + } else { + language + } +} + +/// Map a Koko locale key to a provider-specific locale key. +pub fn provider_locale_key( + provider_id: MetadataProviderId, + locale_key: &str, +) -> String { + let registry = MetadataRegistry::new(); + registry + .provider(&provider_id) + .map(|provider| provider.provider_locale_key(locale_key)) + .unwrap_or_else(|| normalize_locale_key(locale_key)) +} + +/// Whether a provider returns locale-specific metadata and should be stored per locale. +pub fn provider_uses_localized_metadata(provider_id: MetadataProviderId) -> bool { + let registry = MetadataRegistry::new(); + registry + .provider(&provider_id) + .map(|provider| provider.uses_localized_metadata()) + .unwrap_or(false) +} + +/// Remove provider metadata response cache files from the configured data directory. +pub fn clear_metadata_response_cache(data_dir: &str) -> Result { + let cache_dir = metadata_response_cache_dir(data_dir); + if !cache_dir.exists() { + return Ok(0); + } + let count = count_files_recursive(&cache_dir)?; + fs::remove_dir_all(&cache_dir).map_err(|error| error.to_string())?; + Ok(count) +} + +/// Return provider statuses after applying the current settings. +pub fn list_provider_statuses(settings: &MetadataSettings) -> Vec { + let configured_settings: std::collections::HashMap< + MetadataProviderId, + MetadataProviderSettings, + > = settings + .providers + .iter() + .cloned() + .map(|provider| (provider.id.clone(), provider)) + .collect(); + + MetadataRegistry::new() + .descriptors() + .into_iter() + .map(|descriptor| { + let setting = configured_settings.get(&descriptor.id).cloned(); + let enabled = setting + .as_ref() + .map(|provider| provider.enabled) + .unwrap_or(false); + let language = setting + .as_ref() + .map(|provider| provider.language.clone()) + .unwrap_or_else(|| "en-US".into()); + let configured = if descriptor.requires_api_key { + setting + .as_ref() + .map(metadata_provider_api_key_configured) + .unwrap_or(false) + } else { + true + }; + + MetadataProviderStatus { + id: descriptor.id, + display_name: descriptor.display_name, + description: descriptor.description, + supported_kinds: descriptor.supported_kinds, + requires_api_key: descriptor.requires_api_key, + implemented: descriptor.implemented, + role: descriptor.role, + extends_provider_ids: descriptor.extends_provider_ids, + enabled, + configured, + language, + attribution_text: descriptor.attribution_text, + attribution_url: descriptor.attribution_url, + logo_light_url: descriptor.logo_light_url, + logo_dark_url: descriptor.logo_dark_url, + } + }) + .collect() +} + +/// Return stored metadata links for one media item. +pub fn get_item_metadata_summaries( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + let rows = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .order(metadata_links_dsl::provider_id.asc()) + .select(ItemMetadataLink::as_select()) + .load::(conn)?; + + rows.into_iter() + .map(|row| to_item_metadata_summary_with_people(conn, row)) + .collect() +} + +/// Sort stored metadata rows so the current user's preferred locale appears first. +pub fn sort_item_metadata_summaries_for_languages( + summaries: &mut [ItemMetadataSummary], + preferred_languages: &[String], +) { + let rank = preferred_language_rank(preferred_languages); + let fallback_rank = rank.len(); + summaries.sort_by(|left, right| { + let left_rank = rank + .get(&normalize_locale_key(&left.locale_key)) + .copied() + .unwrap_or(fallback_rank); + let right_rank = rank + .get(&normalize_locale_key(&right.locale_key)) + .copied() + .unwrap_or(fallback_rank); + let left_relation_rank = if left.relation_kind == "primary" { 0 } else { 1 }; + let right_relation_rank = if right.relation_kind == "primary" { 0 } else { 1 }; + left_rank + .cmp(&right_rank) + .then_with(|| left_relation_rank.cmp(&right_relation_rank)) + .then_with(|| { + left.provider_id + .as_storage_value() + .cmp(right.provider_id.as_storage_value()) + }) + .then_with(|| left.updated_at.cmp(&right.updated_at).reverse()) + .then_with(|| left.id.cmp(&right.id)) + }); +} + +/// Return one normalized metadata person. +pub fn get_metadata_person( + conn: &mut SqliteConnection, + person_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + people_dsl::metadata_people + .filter(people_dsl::id.eq(person_id)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional() + .map(|person| person.map(to_metadata_person_summary)) +} + +/// Return the best localized row for the same provider person as `person_id`. +pub fn get_metadata_person_for_languages( + conn: &mut SqliteConnection, + person_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + let Some(source_person) = people_dsl::metadata_people + .filter(people_dsl::id.eq(person_id)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional()? + else { + return Ok(None); + }; + + let rows = load_metadata_people_for_row_identity(conn, &source_person)?; + Ok(preferred_person_row(rows, preferred_languages).map(to_metadata_person_summary)) +} + +/// Return library-scoped person rows that are candidates for provider detail enrichment. +pub fn list_metadata_people_for_library( + conn: &mut SqliteConnection, + library_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as link_dsl; + use crate::db::schema::media_items::dsl as items_dsl; + use crate::db::schema::metadata_people::dsl as people_dsl; + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + let rows = people_dsl::metadata_people + .inner_join( + credit_dsl::metadata_person_credits.on(credit_dsl::person_id.eq(people_dsl::id)), + ) + .inner_join(link_dsl::item_metadata_links.on(link_dsl::id.eq(credit_dsl::metadata_link_id))) + .inner_join(items_dsl::media_items.on(items_dsl::id.eq(link_dsl::media_item_id))) + .filter(items_dsl::library_id.eq(library_id)) + .filter(people_dsl::external_id.is_not_null()) + .filter( + people_dsl::biography + .is_null() + .or(people_dsl::gender.is_null()) + .or(people_dsl::birthday.is_null()) + .or(people_dsl::birth_place.is_null()), + ) + .select(MetadataPerson::as_select()) + .load::(conn)?; + + let mut seen = HashSet::new(); + Ok(rows + .into_iter() + .filter_map(|person| { + let provider_id = MetadataProviderId::from_storage_value(&person.provider_id)?; + let external_id = person.external_id?; + let key = ( + provider_id.as_storage_value().to_string(), + external_id.clone(), + normalize_locale_key(&person.locale_key), + ); + seen.insert(key).then_some(MetadataPersonEnrichmentTarget { + id: person.id, + provider_id, + external_id, + locale_key: person.locale_key, + name: person.name, + }) + }) + .collect()) +} + +/// Merge provider-fetched person details into an existing normalized person row. +pub fn update_metadata_person_details( + conn: &mut SqliteConnection, + person_id: i32, + details: &ProviderMetadataPerson, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + let Some(existing) = people_dsl::metadata_people + .filter(people_dsl::id.eq(person_id)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional()? + else { + return Ok(None); + }; + let payload = merged_metadata_person_payload(&existing, details); + diesel::update(people_dsl::metadata_people.filter(people_dsl::id.eq(person_id))) + .set(&payload) + .execute(conn)?; + + people_dsl::metadata_people + .filter(people_dsl::id.eq(person_id)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional() + .map(|person| person.map(to_metadata_person_summary)) +} + +/// Search normalized metadata people and return one preferred locale row per provider identity. +pub fn search_metadata_people_with_preferred_languages( + conn: &mut SqliteConnection, + query: &str, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + let query = query.trim().to_ascii_lowercase(); + if query.is_empty() { + return Ok(Vec::new()); + } + + let matching_credit_person_ids = metadata_person_credit_character_person_ids(conn, &query)?; + let rows = people_dsl::metadata_people + .select(MetadataPerson::as_select()) + .load::(conn)?; + let mut grouped = HashMap::<(String, String), Vec>::new(); + for row in rows { + let identity_key = metadata_person_identity_key(&row); + grouped + .entry((row.provider_id.clone(), identity_key)) + .or_default() + .push(row); + } + + let mut people = grouped + .into_values() + .filter(|rows| { + rows.iter().any(|row| { + metadata_person_matches_query(row, &query) + || matching_credit_person_ids.contains(&row.id) + }) + }) + .filter_map(|rows| preferred_person_row(rows, preferred_languages)) + .map(to_metadata_person_summary) + .collect::>(); + people.sort_by(|left, right| { + left.name + .to_ascii_lowercase() + .cmp(&right.name.to_ascii_lowercase()) + .then_with(|| { + left.provider_id + .as_storage_value() + .cmp(right.provider_id.as_storage_value()) + }) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(people) +} + +fn metadata_person_matches_query( + person: &MetadataPerson, + query: &str, +) -> bool { + person.name.to_ascii_lowercase().contains(query) +} + +fn load_metadata_people_for_row_identity( + conn: &mut SqliteConnection, + person: &MetadataPerson, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + if let Some(external_id) = normalized_external_id(person.external_id.as_deref()) { + return people_dsl::metadata_people + .filter(people_dsl::provider_id.eq(&person.provider_id)) + .filter(people_dsl::external_id.eq(external_id)) + .select(MetadataPerson::as_select()) + .load::(conn); + } + + let identity_key = metadata_person_identity_key(person); + let rows = people_dsl::metadata_people + .filter(people_dsl::provider_id.eq(&person.provider_id)) + .filter(people_dsl::external_id.is_null()) + .select(MetadataPerson::as_select()) + .load::(conn)?; + Ok(rows + .into_iter() + .filter(|row| metadata_person_identity_key(row) == identity_key) + .collect()) +} + +fn metadata_person_credit_character_person_ids( + conn: &mut SqliteConnection, + query: &str, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + let rows = credit_dsl::metadata_person_credits + .select(MetadataPersonCredit::as_select()) + .load::(conn)?; + + Ok(rows + .into_iter() + .filter(|credit| { + credit + .character_name + .as_deref() + .is_some_and(|value| value.to_ascii_lowercase().contains(query)) + }) + .map(|credit| credit.person_id) + .collect()) +} + +/// Return all localized person ids for the same provider person as `person_id`. +pub fn get_metadata_person_locale_peer_ids( + conn: &mut SqliteConnection, + person_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + let Some(source_person) = people_dsl::metadata_people + .filter(people_dsl::id.eq(person_id)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional()? + else { + return Ok(Vec::new()); + }; + + load_metadata_people_for_row_identity(conn, &source_person) + .map(|rows| rows.into_iter().map(|person| person.id).collect()) +} + +/// Return all item credits for one normalized metadata person. +pub fn list_metadata_person_credit_summaries( + conn: &mut SqliteConnection, + person_id: i32, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as link_dsl; + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + let rows = credit_dsl::metadata_person_credits + .inner_join(link_dsl::item_metadata_links) + .filter(credit_dsl::person_id.eq(person_id)) + .order((credit_dsl::sort_order.asc(), link_dsl::updated_at.desc())) + .select(( + MetadataPersonCredit::as_select(), + ItemMetadataLink::as_select(), + )) + .load::<(MetadataPersonCredit, ItemMetadataLink)>(conn)?; + + Ok(rows + .into_iter() + .map(|(credit, link)| MetadataPersonCreditSummary { + id: credit.id, + metadata_link_id: credit.metadata_link_id, + media_item_id: link.media_item_id, + role: credit.role, + department: credit.department, + character_name: credit.character_name, + sort_order: credit.sort_order, + }) + .collect()) +} + +/// Return all item credits for localized rows representing the same provider person. +pub fn list_metadata_person_credit_summaries_for_person_ids( + conn: &mut SqliteConnection, + person_ids: &[i32], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as link_dsl; + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + if person_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = credit_dsl::metadata_person_credits + .inner_join(link_dsl::item_metadata_links) + .filter(credit_dsl::person_id.eq_any(person_ids)) + .order((credit_dsl::sort_order.asc(), link_dsl::updated_at.desc())) + .select(( + MetadataPersonCredit::as_select(), + ItemMetadataLink::as_select(), + )) + .load::<(MetadataPersonCredit, ItemMetadataLink)>(conn)?; + + Ok(rows + .into_iter() + .map(|(credit, link)| MetadataPersonCreditSummary { + id: credit.id, + metadata_link_id: credit.metadata_link_id, + media_item_id: link.media_item_id, + role: credit.role, + department: credit.department, + character_name: credit.character_name, + sort_order: credit.sort_order, + }) + .collect()) +} + +/// Return primary metadata links that were left pending without an active in-memory worker. +pub fn list_pending_item_metadata_links( + conn: &mut SqliteConnection +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::relation_kind.eq("primary")) + .filter(metadata_links_dsl::refresh_state.eq("pending")) + .order(metadata_links_dsl::updated_at.asc()) + .select(ItemMetadataLink::as_select()) + .load::(conn) +} + +/// Return primary metadata links whose automatic refresh interval has elapsed. +pub fn list_due_item_metadata_links( + conn: &mut SqliteConnection, + now: i64, + limit: i64, +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::relation_kind.eq("primary")) + .filter(metadata_links_dsl::refresh_state.ne("pending")) + .filter(metadata_links_dsl::next_refresh_at.le(now)) + .order(metadata_links_dsl::next_refresh_at.asc()) + .limit(limit) + .select(ItemMetadataLink::as_select()) + .load::(conn) +} + +/// Search one metadata provider using the current provider configuration. +pub async fn search_provider( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + query: &str, + media_type: Option<&str>, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} search is not implemented.", + provider_display_name(&provider_id) + ) + })?; + provider.search(settings, query, media_type).await +} + +/// Fetch and normalize one provider metadata snapshot. +pub async fn fetch_provider_metadata_snapshot( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + external_id: &str, + media_type: &str, +) -> Result { + fetch_provider_metadata_snapshot_for_locale_with_options( + settings, + provider_id, + external_id, + media_type, + DEFAULT_METADATA_LOCALE, + MetadataSnapshotFetchOptions::FULL, + ) + .await +} + +/// Fetch and normalize one provider metadata snapshot for a specific Koko locale. +pub async fn fetch_provider_metadata_snapshot_for_locale( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + external_id: &str, + media_type: &str, + locale_key: &str, +) -> Result { + fetch_provider_metadata_snapshot_for_locale_with_options( + settings, + provider_id, + external_id, + media_type, + locale_key, + MetadataSnapshotFetchOptions::FULL, + ) + .await +} + +/// Fetch and normalize one provider metadata snapshot for a specific Koko locale with options. +pub async fn fetch_provider_metadata_snapshot_for_locale_with_options( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + external_id: &str, + media_type: &str, + locale_key: &str, + options: MetadataSnapshotFetchOptions, +) -> Result { + let locale_key = normalize_locale_key(locale_key); + let options_key = options.cache_key_fragment(); + let cache_key = metadata_response_cache_key( + &provider_id, + "item", + &[ + external_id, + media_type, + &locale_key, + options_key, + ], + ); + if let Some(snapshot) = read_metadata_snapshot_cache(&cache_key) { + return Ok(snapshot); + } + + let mut last_error = None; + for fetch_locale in locale_fallback_chain(&locale_key) { + let (localized_settings, provider_locale) = + localized_provider_settings(settings, provider_id.clone(), &fetch_locale); + + let registry = MetadataRegistry::new(); + let result = match registry.provider(&provider_id) { + Some(provider) => { + provider + .fetch_snapshot( + &localized_settings, + external_id, + media_type, + options.include_person_details, + ) + .await + } + None => Err(format!( + "{} metadata fetch is not implemented.", + provider_display_name(&provider_id) + )), + }; + + match result { + Ok(mut snapshot) if snapshot_has_presentable_metadata(&snapshot) => { + snapshot.locale_key = locale_key; + snapshot.provider_locale_key = Some(provider_locale); + write_metadata_snapshot_cache(&cache_key, &snapshot); + return Ok(snapshot); + } + Ok(mut snapshot) => { + snapshot.locale_key = locale_key.clone(); + snapshot.provider_locale_key = Some(provider_locale); + last_error = Some("metadata provider returned an empty localized snapshot".into()); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + format!( + "{} metadata fetch is not implemented.", + provider_display_name(&provider_id) + ) + })) +} + +fn locale_fallback_chain(locale_key: &str) -> Vec { + let normalized = normalize_locale_key(locale_key); + let mut locales = vec![normalized.clone()]; + if let Some(language) = normalized + .split('-') + .next() + .filter(|language| !language.is_empty() && *language != normalized && *language != "en") + { + locales.push(language.to_string()); + } + if !locales + .iter() + .any(|locale| locale == DEFAULT_METADATA_LOCALE) + { + locales.push(DEFAULT_METADATA_LOCALE.to_string()); + } + locales +} + +fn localized_provider_settings( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + locale_key: &str, +) -> (MetadataSettings, String) { + let provider_locale = provider_locale_key(provider_id.clone(), locale_key); + let mut localized_settings = settings.clone(); + if let Some(provider) = localized_settings + .providers + .iter_mut() + .find(|provider| provider.id == provider_id) + { + provider.language = provider_locale.clone(); + } + + (localized_settings, provider_locale) +} + +fn snapshot_has_presentable_metadata(snapshot: &StoredMetadataSnapshot) -> bool { + snapshot + .title + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || snapshot + .overview + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || snapshot.artwork_url.is_some() + || snapshot.backdrop_url.is_some() + || snapshot.provider_payload_json.is_some() +} + +/// Fetch one provider season snapshot for a linked show descendant. +pub async fn fetch_provider_season_metadata_snapshot( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + season_external_id: Option<&str>, +) -> Result { + fetch_provider_season_metadata_snapshot_with_options( + settings, + provider_id, + show_external_id, + season_number, + season_external_id, + MetadataSnapshotFetchOptions::FULL, + ) + .await +} + +/// Fetch one provider season snapshot for a linked show descendant with explicit options. +pub async fn fetch_provider_season_metadata_snapshot_with_options( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + season_external_id: Option<&str>, + options: MetadataSnapshotFetchOptions, +) -> Result { + let provider_language = configured_provider_language(settings, &provider_id); + let season_external_key = season_external_id.unwrap_or_default(); + let season_number_key = season_number.to_string(); + let options_key = options.cache_key_fragment(); + let cache_key = metadata_response_cache_key( + &provider_id, + "season", + &[ + show_external_id, + &season_number_key, + season_external_key, + &provider_language, + options_key, + ], + ); + if let Some(snapshot) = read_metadata_snapshot_cache(&cache_key) { + return Ok(snapshot); + } + + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} season metadata fetch is not implemented.", + provider_display_name(&provider_id) + ) + })?; + let snapshot = provider + .fetch_season_snapshot( + settings, + show_external_id, + season_number, + season_external_id, + options.include_person_details, + ) + .await?; + write_metadata_snapshot_cache(&cache_key, &snapshot); + Ok(snapshot) +} + +/// Fetch and normalize one provider season snapshot for a specific Koko locale. +pub async fn fetch_provider_season_metadata_snapshot_for_locale( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + season_external_id: Option<&str>, + locale_key: &str, +) -> Result { + fetch_provider_season_metadata_snapshot_for_locale_with_options( + settings, + provider_id, + show_external_id, + season_number, + season_external_id, + locale_key, + MetadataSnapshotFetchOptions::FULL, + ) + .await +} + +/// Fetch and normalize one provider season snapshot for a specific Koko locale with options. +pub async fn fetch_provider_season_metadata_snapshot_for_locale_with_options( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + season_external_id: Option<&str>, + locale_key: &str, + options: MetadataSnapshotFetchOptions, +) -> Result { + let locale_key = normalize_locale_key(locale_key); + let season_external_key = season_external_id.unwrap_or_default(); + let season_number_key = season_number.to_string(); + let options_key = options.cache_key_fragment(); + let cache_key = metadata_response_cache_key( + &provider_id, + "season", + &[ + show_external_id, + &season_number_key, + season_external_key, + &locale_key, + options_key, + ], + ); + if let Some(snapshot) = read_metadata_snapshot_cache(&cache_key) { + return Ok(snapshot); + } + + let mut last_error = None; + for fetch_locale in locale_fallback_chain(&locale_key) { + let (localized_settings, provider_locale) = + localized_provider_settings(settings, provider_id.clone(), &fetch_locale); + + let registry = MetadataRegistry::new(); + let result = match registry.provider(&provider_id) { + Some(provider) => { + provider + .fetch_season_snapshot( + &localized_settings, + show_external_id, + season_number, + season_external_id, + options.include_person_details, + ) + .await + } + None => Err(format!( + "{} season metadata fetch is not implemented.", + provider_display_name(&provider_id) + )), + }; + + match result { + Ok(mut snapshot) if snapshot_has_presentable_metadata(&snapshot) => { + snapshot.locale_key = locale_key; + snapshot.provider_locale_key = Some(provider_locale); + write_metadata_snapshot_cache(&cache_key, &snapshot); + return Ok(snapshot); + } + Ok(mut snapshot) => { + snapshot.locale_key = locale_key.clone(); + snapshot.provider_locale_key = Some(provider_locale); + last_error = + Some("metadata provider returned an empty localized season snapshot".into()); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + format!( + "{} season metadata fetch is not implemented.", + provider_display_name(&provider_id) + ) + })) +} + +/// Fetch one provider episode snapshot for a linked show descendant. +pub async fn fetch_provider_episode_metadata_snapshot( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + episode_number: i32, + episode_external_id: Option<&str>, +) -> Result { + fetch_provider_episode_metadata_snapshot_with_options( + settings, + provider_id, + show_external_id, + season_number, + episode_number, + episode_external_id, + MetadataSnapshotFetchOptions::FULL, + ) + .await +} + +/// Fetch one provider episode snapshot for a linked show descendant with explicit options. +pub async fn fetch_provider_episode_metadata_snapshot_with_options( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + episode_number: i32, + episode_external_id: Option<&str>, + options: MetadataSnapshotFetchOptions, +) -> Result { + let provider_language = configured_provider_language(settings, &provider_id); + let episode_external_key = episode_external_id.unwrap_or_default(); + let season_number_key = season_number.to_string(); + let episode_number_key = episode_number.to_string(); + let options_key = options.cache_key_fragment(); + let cache_key = metadata_response_cache_key( + &provider_id, + "episode", + &[ + show_external_id, + &season_number_key, + &episode_number_key, + episode_external_key, + &provider_language, + options_key, + ], + ); + if let Some(snapshot) = read_metadata_snapshot_cache(&cache_key) { + return Ok(snapshot); + } + + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} episode metadata fetch is not implemented.", + provider_display_name(&provider_id) + ) + })?; + let snapshot = provider + .fetch_episode_snapshot( + settings, + show_external_id, + season_number, + episode_number, + episode_external_id, + options.include_person_details, + ) + .await?; + write_metadata_snapshot_cache(&cache_key, &snapshot); + Ok(snapshot) +} + +/// Fetch and normalize one provider episode snapshot for a specific Koko locale. +pub async fn fetch_provider_episode_metadata_snapshot_for_locale( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, + season_number: i32, + episode_number: i32, + episode_external_id: Option<&str>, + locale_key: &str, +) -> Result { + fetch_provider_episode_metadata_snapshot_for_locale_with_options( + settings, + provider_id, + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number, + episode_number, + episode_external_id, + locale_key, + options: MetadataSnapshotFetchOptions::FULL, + }, + ) + .await +} + +/// Fetch and normalize one provider episode snapshot for a specific Koko locale with options. +pub async fn fetch_provider_episode_metadata_snapshot_for_locale_with_options( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + fetch: ProviderEpisodeMetadataSnapshotFetch<'_>, +) -> Result { + let ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number, + episode_number, + episode_external_id, + locale_key, + options, + } = fetch; + let locale_key = normalize_locale_key(locale_key); + let episode_external_key = episode_external_id.unwrap_or_default(); + let season_number_key = season_number.to_string(); + let episode_number_key = episode_number.to_string(); + let options_key = options.cache_key_fragment(); + let cache_key = metadata_response_cache_key( + &provider_id, + "episode", + &[ + show_external_id, + &season_number_key, + &episode_number_key, + episode_external_key, + &locale_key, + options_key, + ], + ); + if let Some(snapshot) = read_metadata_snapshot_cache(&cache_key) { + return Ok(snapshot); + } + + let mut last_error = None; + for fetch_locale in locale_fallback_chain(&locale_key) { + let (localized_settings, provider_locale) = + localized_provider_settings(settings, provider_id.clone(), &fetch_locale); + + let registry = MetadataRegistry::new(); + let result = match registry.provider(&provider_id) { + Some(provider) => { + provider + .fetch_episode_snapshot( + &localized_settings, + show_external_id, + season_number, + episode_number, + episode_external_id, + options.include_person_details, + ) + .await + } + None => Err(format!( + "{} episode metadata fetch is not implemented.", + provider_display_name(&provider_id) + )), + }; + + match result { + Ok(mut snapshot) if snapshot_has_presentable_metadata(&snapshot) => { + snapshot.locale_key = locale_key; + snapshot.provider_locale_key = Some(provider_locale); + write_metadata_snapshot_cache(&cache_key, &snapshot); + return Ok(snapshot); + } + Ok(mut snapshot) => { + snapshot.locale_key = locale_key.clone(); + snapshot.provider_locale_key = Some(provider_locale); + last_error = + Some("metadata provider returned an empty localized episode snapshot".into()); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + format!( + "{} episode metadata fetch is not implemented.", + provider_display_name(&provider_id) + ) + })) +} + +/// Guess the best provider movie match for one library item. +pub async fn guess_provider_movie_match( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&provider_id) else { + return Ok(None); + }; + provider + .guess_movie_match(settings, relative_path, display_title) + .await +} + +/// Guess the best provider show match for one show item. +pub async fn guess_provider_show_match( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&provider_id) else { + return Ok(None); + }; + provider + .guess_show_match(settings, relative_path, display_title) + .await +} + +/// Load provider descendant metadata targets for one linked show. +pub async fn load_provider_show_descendant_targets( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + show_external_id: &str, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} show descendant lookup is not implemented.", + provider_display_name(&provider_id) + ) + })?; + provider + .load_show_descendant_targets(settings, show_external_id) + .await +} + +/// Resolve item-level metadata fields contributed by a secondary provider. +pub async fn fetch_provider_secondary_metadata( + provider_id: MetadataProviderId, + item_type: &str, + database_id: &str, + external_id: &str, + locale_key: &str, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} secondary metadata lookup is not implemented.", + provider_display_name(&provider_id) + ) + })?; + provider + .fetch_secondary_metadata(item_type, database_id, external_id, locale_key) + .await +} + +/// Resolve collection-level metadata fields contributed by a secondary provider. +pub async fn fetch_provider_secondary_collection_metadata( + provider_id: MetadataProviderId, + item_type: &str, + database_id: &str, + external_id: &str, + locale_key: &str, +) -> Result, String> { + let registry = MetadataRegistry::new(); + let provider = registry.provider(&provider_id).ok_or_else(|| { + format!( + "{} secondary collection metadata lookup is not implemented.", + provider_display_name(&provider_id) + ) + })?; + provider + .fetch_secondary_collection_metadata(item_type, database_id, external_id, locale_key) + .await +} + +/// Fetch provider-side person metadata for one localized person row. +pub async fn fetch_provider_person_metadata_for_locale( + settings: &MetadataSettings, + provider_id: MetadataProviderId, + external_id: &str, + locale_key: &str, +) -> Result, String> { + let (localized_settings, _) = + localized_provider_settings(settings, provider_id.clone(), locale_key); + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&provider_id) else { + return Ok(None); + }; + + provider + .fetch_person_metadata(&localized_settings, external_id) + .await +} + +/// Upsert a stored metadata snapshot for one media item. +pub fn upsert_item_metadata_snapshot( + conn: &mut SqliteConnection, + item_id: i32, + snapshot: &StoredMetadataSnapshot, +) -> Result { + upsert_item_metadata_snapshot_with_refresh_interval( + conn, + item_id, + snapshot, + Some(DEFAULT_METADATA_REFRESH_INTERVAL_SECONDS), + ) +} + +/// Upsert a stored metadata snapshot using an explicit automatic refresh interval. +pub fn upsert_item_metadata_snapshot_with_refresh_interval( + conn: &mut SqliteConnection, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + refresh_interval_seconds: Option, +) -> Result { + let details = provider_metadata_details(snapshot); + upsert_item_metadata_link( + conn, + item_id, + snapshot, + &details, + "primary", + refresh_interval_seconds, + ) +} + +/// Upsert a stored metadata link for one media item. +pub fn upsert_item_metadata_link( + conn: &mut SqliteConnection, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + details: &ProviderMetadataDetails, + relation_kind: &str, + refresh_interval_seconds: Option, +) -> Result { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + let relation_kind = relation_kind.trim(); + let relation_kind = + if relation_kind.is_empty() { "primary" } else { relation_kind }.to_string(); + configure_sqlite_connection(conn)?; + retry_sqlite_write(|| { + let existing = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(snapshot.provider_id.as_storage_value())) + .filter(metadata_links_dsl::relation_kind.eq(&relation_kind)) + .filter(metadata_links_dsl::locale_key.eq(&snapshot.locale_key)) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional()?; + let keep_cached_artwork = existing + .as_ref() + .map(|row| { + !metadata_refresh_target_changed( + row, + snapshot.provider_id.as_storage_value(), + &snapshot.external_id, + snapshot.media_type.as_deref(), + ) && row.artwork_url == snapshot.artwork_url + }) + .unwrap_or(false); + let keep_cached_backdrop = existing + .as_ref() + .map(|row| { + !metadata_refresh_target_changed( + row, + snapshot.provider_id.as_storage_value(), + &snapshot.external_id, + snapshot.media_type.as_deref(), + ) && row.backdrop_url == snapshot.backdrop_url + }) + .unwrap_or(false); + let logo_url = details.logo_url.clone(); + let keep_cached_logo = existing + .as_ref() + .map(|row| { + !metadata_refresh_target_changed( + row, + snapshot.provider_id.as_storage_value(), + &snapshot.external_id, + snapshot.media_type.as_deref(), + ) && row.logo_url == logo_url + }) + .unwrap_or(false); + + let payload = NewItemMetadataLink { + media_item_id: item_id, + provider_id: snapshot.provider_id.as_storage_value().to_string(), + external_id: snapshot.external_id.clone(), + title: snapshot.title.clone(), + overview: snapshot.overview.clone(), + tagline: details.tagline.clone(), + artwork_url: snapshot.artwork_url.clone(), + backdrop_url: snapshot.backdrop_url.clone(), + release_year: snapshot.release_year, + media_type: snapshot.media_type.clone(), + relation_kind: relation_kind.clone(), + match_state: "linked".into(), + logo_url, + cached_logo_path: if keep_cached_logo { + existing + .as_ref() + .and_then(|row| row.cached_logo_path.clone()) + } else { + None + }, + genres_json: serde_json::to_string(&details.genres).ok(), + rating: details.rating, + content_rating: details.content_rating.clone(), + locale_key: snapshot.locale_key.clone(), + provider_locale_key: snapshot.provider_locale_key.clone(), + cached_artwork_path: if keep_cached_artwork { + existing + .as_ref() + .and_then(|row| row.cached_artwork_path.clone()) + } else { + None + }, + cached_backdrop_path: if keep_cached_backdrop { + existing + .as_ref() + .and_then(|row| row.cached_backdrop_path.clone()) + } else { + None + }, + refresh_state: "fresh".into(), + refresh_interval_seconds: refresh_interval_seconds.unwrap_or(0), + last_refreshed_at: Some(current_timestamp()), + next_refresh_at: refresh_interval_seconds + .map(|interval| current_timestamp() + interval), + refresh_error: None, + updated_at: Some(current_timestamp()), + }; + + if let Some(existing) = existing { + diesel::update( + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::id.eq(existing.id)), + ) + .set(&payload) + .execute(conn)?; + } else { + diesel::insert_into(metadata_links_dsl::item_metadata_links) + .values(&payload) + .on_conflict(( + metadata_links_dsl::media_item_id, + metadata_links_dsl::provider_id, + metadata_links_dsl::relation_kind, + metadata_links_dsl::locale_key, + )) + .do_update() + .set(&payload) + .execute(conn)?; + } + + let row = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(snapshot.provider_id.as_storage_value())) + .filter(metadata_links_dsl::relation_kind.eq(&relation_kind)) + .filter(metadata_links_dsl::locale_key.eq(&snapshot.locale_key)) + .select(ItemMetadataLink::as_select()) + .first(conn)?; + + sync_metadata_extras_for_link(conn, row.id, details, current_timestamp())?; + + if relation_kind == "primary" { + sync_item_metadata_collections(conn, row.id, row.media_item_id, snapshot, details)?; + sync_item_metadata_external_ids(conn, row.id, snapshot, details)?; + sync_item_metadata_people(conn, row.id, snapshot, details)?; + } + + to_item_metadata_summary_with_people(conn, row) + }) +} + +fn normalized_youtube_url(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| youtube_watch_url(value).unwrap_or_else(|| value.to_string())) +} + +fn provider_metadata_extras(details: &ProviderMetadataDetails) -> Vec { + let mut extras = Vec::new(); + let mut seen = HashSet::<(String, String)>::new(); + for extra in &details.extras { + push_provider_metadata_extra(&mut extras, &mut seen, extra.clone()); + } + if let Some(url) = details.trailer_url.as_deref() { + push_provider_metadata_extra( + &mut extras, + &mut seen, + ProviderMetadataExtra { + extra_type: METADATA_EXTRA_TYPE_TRAILER.to_string(), + title: details.trailer_title.clone(), + url: url.to_string(), + duration_seconds: None, + thumbnail_url: None, + sort_order: 0, + }, + ); + } + if let Some(url) = details.theme_song_url.as_deref() { + push_provider_metadata_extra( + &mut extras, + &mut seen, + ProviderMetadataExtra { + extra_type: METADATA_EXTRA_TYPE_THEME_SONG.to_string(), + title: None, + url: url.to_string(), + duration_seconds: None, + thumbnail_url: None, + sort_order: 0, + }, + ); + } + extras +} + +fn push_provider_metadata_extra( + extras: &mut Vec, + seen: &mut HashSet<(String, String)>, + extra: ProviderMetadataExtra, +) { + let Some(extra_type) = normalize_metadata_extra_type(&extra.extra_type) else { + return; + }; + let Some(url) = normalized_youtube_url(Some(&extra.url)) else { + return; + }; + if !seen.insert((extra_type.clone(), url.clone())) { + return; + } + extras.push(ProviderMetadataExtra { + extra_type, + title: extra + .title + .map(|title| title.trim().to_string()) + .filter(|title| !title.is_empty()), + duration_seconds: extra.duration_seconds.filter(|duration| *duration > 0), + thumbnail_url: extra + .thumbnail_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| youtube_thumbnail_url(&url)), + url, + sort_order: extra.sort_order, + }); +} + +fn sync_metadata_extras_for_link( + conn: &mut SqliteConnection, + metadata_link_id: i32, + details: &ProviderMetadataDetails, + now: i64, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::metadata_extras::dsl as extras_dsl; + + diesel::delete( + extras_dsl::metadata_extras.filter(extras_dsl::metadata_link_id.eq(metadata_link_id)), + ) + .execute(conn)?; + + for (index, extra) in provider_metadata_extras(details).into_iter().enumerate() { + let external_media_id = upsert_external_media( + conn, + &extra.url, + extra.title.as_deref(), + extra.duration_seconds, + extra.thumbnail_url.as_deref(), + now, + )?; + let row = NewMetadataExtra { + metadata_link_id: Some(metadata_link_id), + collection_id: None, + external_media_id, + extra_type: extra.extra_type, + title: extra.title, + sort_order: if extra.sort_order >= 0 { extra.sort_order } else { index as i32 }, + updated_at: Some(now), + }; + diesel::insert_into(extras_dsl::metadata_extras) + .values(&row) + .execute(conn)?; + } + + Ok(()) +} + +fn sync_collection_theme_song_extra( + conn: &mut SqliteConnection, + collection_id: i32, + theme_song_url: &str, + now: i64, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::metadata_extras::dsl as extras_dsl; + + let Some(url) = normalized_youtube_url(Some(theme_song_url)) else { + return Ok(()); + }; + diesel::delete( + extras_dsl::metadata_extras + .filter(extras_dsl::collection_id.eq(collection_id)) + .filter(extras_dsl::extra_type.eq(METADATA_EXTRA_TYPE_THEME_SONG)), + ) + .execute(conn)?; + + let external_media_id = upsert_external_media( + conn, + &url, + None, + None, + youtube_thumbnail_url(&url).as_deref(), + now, + )?; + let row = NewMetadataExtra { + metadata_link_id: None, + collection_id: Some(collection_id), + external_media_id, + extra_type: METADATA_EXTRA_TYPE_THEME_SONG.to_string(), + title: None, + sort_order: 0, + updated_at: Some(now), + }; + diesel::insert_into(extras_dsl::metadata_extras) + .values(&row) + .execute(conn)?; + + Ok(()) +} + +fn upsert_external_media( + conn: &mut SqliteConnection, + url: &str, + title: Option<&str>, + duration_seconds: Option, + thumbnail_url: Option<&str>, + now: i64, +) -> Result { + use crate::db::schema::external_media::dsl as media_dsl; + + let url = normalized_youtube_url(Some(url)).unwrap_or_else(|| url.trim().to_string()); + let (source, external_id) = external_media_identity(&url); + let existing = media_dsl::external_media + .filter(media_dsl::url.eq(&url)) + .select(ExternalMedia::as_select()) + .first::(conn) + .optional()?; + let title = title + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| existing.as_ref().and_then(|row| row.title.clone())); + let thumbnail_url = thumbnail_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| youtube_thumbnail_url(&url)) + .or_else(|| existing.as_ref().and_then(|row| row.thumbnail_url.clone())); + let payload = NewExternalMedia { + source, + external_id, + url: url.clone(), + media_kind: "video".to_string(), + title, + duration_seconds: duration_seconds + .filter(|duration| *duration > 0) + .or_else(|| existing.as_ref().and_then(|row| row.duration_seconds)), + thumbnail_url, + updated_at: Some(now), + }; + + if let Some(existing) = existing { + diesel::update(media_dsl::external_media.filter(media_dsl::id.eq(existing.id))) + .set(&payload) + .execute(conn)?; + Ok(existing.id) + } else { + diesel::insert_into(media_dsl::external_media) + .values(&payload) + .execute(conn)?; + media_dsl::external_media + .filter(media_dsl::url.eq(&url)) + .select(media_dsl::id) + .first::(conn) + } +} + +fn youtube_thumbnail_url(url: &str) -> Option { + extract_youtube_video_id(url) + .map(|video_id| format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg")) +} + +fn external_media_identity(url: &str) -> (String, Option) { + if let Some(video_id) = extract_youtube_video_id(url) { + return ("youtube".to_string(), Some(video_id)); + } + let source = reqwest::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(ToOwned::to_owned)) + .map(|host| host.trim_start_matches("www.").to_ascii_lowercase()) + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "url".to_string()); + (source, None) +} + +/// Create or update one metadata-link refresh state for asynchronous work tracking. +pub fn set_item_metadata_refresh_state( + conn: &mut SqliteConnection, + item_id: i32, + provider_id: MetadataProviderId, + external_id: &str, + media_type: Option<&str>, + refresh_state: &str, + refresh_error: Option<&str>, +) -> Result { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + configure_sqlite_connection(conn)?; + retry_sqlite_write(|| { + let existing = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn) + .optional()?; + let keep_cached_paths = existing + .as_ref() + .map(|row| { + !metadata_refresh_target_changed( + row, + provider_id.as_storage_value(), + external_id, + media_type, + ) + }) + .unwrap_or(false); + + let payload = NewItemMetadataLink { + media_item_id: item_id, + provider_id: provider_id.as_storage_value().to_string(), + external_id: external_id.to_string(), + title: existing.as_ref().and_then(|row| row.title.clone()), + overview: existing.as_ref().and_then(|row| row.overview.clone()), + tagline: existing.as_ref().and_then(|row| row.tagline.clone()), + artwork_url: existing.as_ref().and_then(|row| row.artwork_url.clone()), + backdrop_url: existing.as_ref().and_then(|row| row.backdrop_url.clone()), + release_year: existing.as_ref().and_then(|row| row.release_year), + media_type: media_type + .map(str::to_string) + .or_else(|| existing.as_ref().and_then(|row| row.media_type.clone())), + relation_kind: existing + .as_ref() + .map(|row| row.relation_kind.clone()) + .unwrap_or_else(|| "primary".into()), + match_state: existing + .as_ref() + .map(|row| row.match_state.clone()) + .unwrap_or_else(|| "linked".into()), + logo_url: existing.as_ref().and_then(|row| row.logo_url.clone()), + cached_logo_path: existing + .as_ref() + .and_then(|row| row.cached_logo_path.clone()), + genres_json: existing.as_ref().and_then(|row| row.genres_json.clone()), + rating: existing.as_ref().and_then(|row| row.rating), + content_rating: existing.as_ref().and_then(|row| row.content_rating.clone()), + locale_key: existing + .as_ref() + .map(|row| row.locale_key.clone()) + .unwrap_or_else(|| DEFAULT_METADATA_LOCALE.to_string()), + provider_locale_key: existing + .as_ref() + .and_then(|row| row.provider_locale_key.clone()), + cached_artwork_path: if keep_cached_paths { + existing + .as_ref() + .and_then(|row| row.cached_artwork_path.clone()) + } else { + None + }, + cached_backdrop_path: if keep_cached_paths { + existing + .as_ref() + .and_then(|row| row.cached_backdrop_path.clone()) + } else { + None + }, + refresh_state: refresh_state.to_string(), + refresh_interval_seconds: existing + .as_ref() + .map(|row| row.refresh_interval_seconds) + .unwrap_or(DEFAULT_METADATA_REFRESH_INTERVAL_SECONDS), + last_refreshed_at: existing.as_ref().and_then(|row| row.last_refreshed_at), + next_refresh_at: existing.as_ref().and_then(|row| row.next_refresh_at), + refresh_error: refresh_error.map(str::to_string), + updated_at: Some(current_timestamp()), + }; + + if let Some(existing) = existing { + diesel::update( + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::id.eq(existing.id)), + ) + .set(&payload) + .execute(conn)?; + } else { + diesel::insert_into(metadata_links_dsl::item_metadata_links) + .values(&payload) + .on_conflict(( + metadata_links_dsl::media_item_id, + metadata_links_dsl::provider_id, + metadata_links_dsl::relation_kind, + metadata_links_dsl::locale_key, + )) + .do_update() + .set(&payload) + .execute(conn)?; + } + + let row = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::provider_id.eq(provider_id.as_storage_value())) + .select(ItemMetadataLink::as_select()) + .first(conn)?; + + to_item_metadata_summary_with_people(conn, row) + }) +} + +fn metadata_refresh_target_changed( + existing: &ItemMetadataLink, + provider_id: &str, + external_id: &str, + media_type: Option<&str>, +) -> bool { + existing.provider_id != provider_id + || existing.external_id != external_id + || existing.media_type.as_deref() != media_type +} + +fn provider_metadata_details(snapshot: &StoredMetadataSnapshot) -> ProviderMetadataDetails { + let registry = MetadataRegistry::new(); + registry + .provider(&snapshot.provider_id) + .map(|provider| provider.metadata_details(snapshot)) + .unwrap_or_default() +} + +/// Return collection summaries derived from stored metadata for the requested library scope. +pub fn list_metadata_collection_summaries( + conn: &mut SqliteConnection, + library_id: Option, +) -> Result, diesel::result::Error> { + list_metadata_collection_summaries_with_preferred_languages(conn, library_id, &[], &[]) +} + +/// Return collection summaries merged by provider order and preferred metadata locale. +pub fn list_metadata_collection_summaries_with_preferred_languages( + conn: &mut SqliteConnection, + library_id: Option, + preferred_languages: &[String], + provider_order: &[MetadataProviderId], +) -> Result, diesel::result::Error> { + use crate::db::schema::media_items::dsl as media_items_dsl; + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + use crate::db::schema::metadata_collections::dsl as collections_dsl; + + let mut item_query = media_items_dsl::media_items.into_boxed(); + if let Some(library_id) = library_id { + item_query = item_query.filter(media_items_dsl::library_id.eq(library_id)); + } + let items = item_query + .select(MediaItem::as_select()) + .load::(conn)?; + if items.is_empty() { + return Ok(Vec::new()); + } + + let items_by_id = items + .iter() + .cloned() + .map(|item| (item.id, item)) + .collect::>(); + let item_ids = items_by_id.keys().copied().collect::>(); + let collection_item_rows = collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::media_item_id.eq_any(&item_ids)) + .select(MetadataCollectionItem::as_select()) + .load::(conn)?; + if collection_item_rows.is_empty() { + return Ok(Vec::new()); + } + + let collection_ids = collection_item_rows + .iter() + .map(|item| item.collection_id) + .collect::>(); + let collection_rows = collections_dsl::metadata_collections + .filter(collections_dsl::id.eq_any(collection_ids)) + .select(MetadataCollection::as_select()) + .load::(conn)?; + let collection_theme_song_urls = metadata_extra_urls_by_collection_id( + conn, + &collection_rows + .iter() + .map(|collection| collection.id) + .collect::>(), + METADATA_EXTRA_TYPE_THEME_SONG, + )?; + let collections_by_id = collection_rows + .into_iter() + .map(|collection| (collection.id, collection)) + .collect::>(); + + let mut grouped = HashMap::<(String, String), (Vec, HashSet)>::new(); + for collection_item in collection_item_rows { + let Some(collection) = collections_by_id.get(&collection_item.collection_id) else { + continue; + }; + let Some(root_id) = root_media_item_id(collection_item.media_item_id, &items_by_id) else { + continue; + }; + + grouped + .entry(( + collection.source_provider_id.clone(), + collection.source_external_id.clone(), + )) + .and_modify(|(collections, item_ids)| { + if !collections + .iter() + .any(|existing| existing.id == collection.id) + { + collections.push(collection.clone()); + } + item_ids.insert(root_id); + }) + .or_insert_with(|| { + let mut item_ids = HashSet::new(); + item_ids.insert(root_id); + (vec![collection.clone()], item_ids) + }); + } + + let provider_rank = provider_order + .iter() + .enumerate() + .map(|(index, provider)| (provider.as_storage_value().to_string(), index)) + .collect::>(); + let fallback_provider_rank = provider_rank.len(); + let language_rank = preferred_language_rank(preferred_languages); + let fallback_language_rank = language_rank.len(); + + let mut summaries = grouped + .into_values() + .filter_map(|(mut collections, item_ids)| { + collections.sort_by(|left, right| { + let left_provider_rank = provider_rank + .get(&left.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let right_provider_rank = provider_rank + .get(&right.provider_id) + .copied() + .unwrap_or(fallback_provider_rank); + let left_relation_rank = if left.relation_kind == "primary" { 0 } else { 1 }; + let right_relation_rank = if right.relation_kind == "primary" { 0 } else { 1 }; + let left_language_rank = language_rank + .get(&normalize_locale_key(&left.locale_key)) + .copied() + .unwrap_or(fallback_language_rank); + let right_language_rank = language_rank + .get(&normalize_locale_key(&right.locale_key)) + .copied() + .unwrap_or(fallback_language_rank); + + left_provider_rank + .cmp(&right_provider_rank) + .then_with(|| left_relation_rank.cmp(&right_relation_rank)) + .then_with(|| left_language_rank.cmp(&right_language_rank)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| right.id.cmp(&left.id)) + }); + let primary = collections.first()?.clone(); + let mut item_ids = item_ids.into_iter().collect::>(); + item_ids.sort_unstable(); + Some(MetadataCollectionSummary { + id: format!( + "collection:{}:{}", + primary.source_provider_id, primary.source_external_id + ), + provider_id: metadata_provider_id_from_db(&primary.provider_id), + external_id: primary.external_id.clone(), + name: first_collection_string(&collections, |collection| collection.name.as_ref()) + .unwrap_or_else(|| primary.source_external_id.clone()), + overview: first_collection_string(&collections, |collection| { + collection.overview.as_ref() + }), + artwork_url: first_collection_string(&collections, |collection| { + collection.artwork_url.as_ref() + }), + backdrop_url: first_collection_string(&collections, |collection| { + collection.backdrop_url.as_ref() + }), + theme_song_url: first_collection_extra_url( + &collections, + &collection_theme_song_urls, + ), + item_count: item_ids.len(), + item_ids, + }) + }) + .collect::>(); + summaries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(summaries) +} + +fn metadata_extra_urls_by_collection_id( + conn: &mut SqliteConnection, + collection_ids: &[i32], + extra_type: &str, +) -> Result, diesel::result::Error> { + use crate::db::schema::external_media::dsl as media_dsl; + use crate::db::schema::metadata_extras::dsl as extras_dsl; + + if collection_ids.is_empty() { + return Ok(HashMap::new()); + } + + let collection_ids = collection_ids.iter().copied().map(Some).collect::>(); + let rows = extras_dsl::metadata_extras + .inner_join(media_dsl::external_media) + .filter(extras_dsl::collection_id.eq_any(collection_ids)) + .filter(extras_dsl::extra_type.eq(extra_type)) + .order(( + extras_dsl::collection_id.asc(), + extras_dsl::sort_order.asc(), + extras_dsl::id.asc(), + )) + .select((extras_dsl::collection_id, media_dsl::url)) + .load::<(Option, String)>(conn)?; + + let mut urls = HashMap::new(); + for (collection_id, url) in rows { + let Some(collection_id) = collection_id else { + continue; + }; + urls.entry(collection_id).or_insert(url); + } + Ok(urls) +} + +fn first_collection_string( + collections: &[MetadataCollection], + value: F, +) -> Option +where + F: Fn(&MetadataCollection) -> Option<&String>, +{ + collections.iter().find_map(|collection| { + value(collection) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn first_collection_extra_url( + collections: &[MetadataCollection], + urls_by_collection_id: &HashMap, +) -> Option { + collections + .iter() + .find_map(|collection| urls_by_collection_id.get(&collection.id).cloned()) +} + +/// Upsert a secondary collection metadata row with a provider-supplied theme-song URL. +pub fn upsert_secondary_collection_theme_song_url( + conn: &mut SqliteConnection, + source_collection_id: i32, + provider_id: MetadataProviderId, + item_type: &str, + database_id: &str, + external_id: &str, + theme_song_url: &str, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + use crate::db::schema::metadata_collections::dsl as collections_dsl; + + let theme_song_url = theme_song_url.trim(); + if theme_song_url.is_empty() { + return Ok(()); + } + + let source_collection = collections_dsl::metadata_collections + .filter(collections_dsl::id.eq(source_collection_id)) + .select(MetadataCollection::as_select()) + .first::(conn)?; + let now = current_timestamp(); + let secondary_collection = ProviderMetadataCollection { + external_id: format!("{item_type}:{database_id}:{external_id}"), + name: None, + overview: None, + artwork_url: None, + backdrop_url: None, + theme_song_url: Some(theme_song_url.to_string()), + }; + let secondary_collection_id = upsert_metadata_collection( + conn, + MetadataCollectionUpsert { + provider_id: provider_id.as_storage_value(), + source_provider_id: &source_collection.source_provider_id, + source_external_id: &source_collection.source_external_id, + relation_kind: "secondary", + locale_key: DEFAULT_METADATA_LOCALE, + provider_locale_key: None, + collection: secondary_collection, + now, + }, + )?; + + diesel::delete( + collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::collection_id.eq(secondary_collection_id)), + ) + .execute(conn)?; + + let source_collection_ids = collections_dsl::metadata_collections + .filter(collections_dsl::source_provider_id.eq(&source_collection.source_provider_id)) + .filter(collections_dsl::source_external_id.eq(&source_collection.source_external_id)) + .filter(collections_dsl::relation_kind.eq("primary")) + .select(collections_dsl::id) + .load::(conn)?; + let source_items = collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::collection_id.eq_any(source_collection_ids)) + .select(MetadataCollectionItem::as_select()) + .load::(conn)?; + + let mut seen_items = HashSet::new(); + for source_item in source_items { + if !seen_items.insert(source_item.media_item_id) { + continue; + } + let row = NewMetadataCollectionItem { + collection_id: secondary_collection_id, + media_item_id: source_item.media_item_id, + metadata_link_id: source_item.metadata_link_id, + updated_at: Some(now), + }; + diesel::insert_into(collection_items_dsl::metadata_collection_items) + .values(&row) + .execute(conn)?; + } + + Ok(()) +} + +/// Return the first stored metadata link for a media item. +pub fn get_primary_item_metadata_link( + conn: &mut SqliteConnection, + item_id: i32, +) -> Result, diesel::result::Error> { + get_preferred_item_metadata_link_for_languages( + conn, + item_id, + &[DEFAULT_METADATA_LOCALE.to_string()], + ) +} + +/// Return the best stored primary metadata link for the requested language order. +pub fn get_preferred_item_metadata_link_for_languages( + conn: &mut SqliteConnection, + item_id: i32, + preferred_languages: &[String], +) -> Result, diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + + let rows = metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::media_item_id.eq(item_id)) + .filter(metadata_links_dsl::relation_kind.eq("primary")) + .order(metadata_links_dsl::updated_at.desc()) + .select(ItemMetadataLink::as_select()) + .load::(conn)?; + + if rows.is_empty() { + return Ok(None); + } + + let mut languages = preferred_languages + .iter() + .map(|language| normalize_locale_key(language)) + .filter(|language| !language.is_empty()) + .collect::>(); + if !languages + .iter() + .any(|language| language == DEFAULT_METADATA_LOCALE) + { + languages.push(DEFAULT_METADATA_LOCALE.to_string()); + } + + Ok(languages + .iter() + .find_map(|language| rows.iter().find(|row| row.locale_key == *language).cloned()) + .or_else(|| rows.into_iter().next())) +} + +fn preferred_language_rank(preferred_languages: &[String]) -> HashMap { + let mut languages = preferred_languages + .iter() + .map(|language| normalize_locale_key(language)) + .filter(|language| !language.is_empty()) + .collect::>(); + if !languages + .iter() + .any(|language| language == DEFAULT_METADATA_LOCALE) + { + languages.push(DEFAULT_METADATA_LOCALE.to_string()); + } + + languages + .into_iter() + .enumerate() + .map(|(index, language)| (language, index)) + .collect() +} + +fn preferred_person_row( + rows: Vec, + preferred_languages: &[String], +) -> Option { + let rank = preferred_language_rank(preferred_languages); + let fallback_rank = rank.len(); + rows.into_iter().min_by(|left, right| { + let left_rank = rank + .get(&normalize_locale_key(&left.locale_key)) + .copied() + .unwrap_or(fallback_rank); + let right_rank = rank + .get(&normalize_locale_key(&right.locale_key)) + .copied() + .unwrap_or(fallback_rank); + left_rank + .cmp(&right_rank) + .then_with(|| left.updated_at.cmp(&right.updated_at).reverse()) + .then_with(|| left.id.cmp(&right.id)) + }) +} + +/// Extract presentation-ready metadata from a stored link payload. +fn presentation_from_metadata_link(link: &ItemMetadataLink) -> LinkedMetadataPresentation { + let tagline = link.tagline.clone(); + let overview = link.overview.clone(); + let genres = link + .genres_json + .as_deref() + .and_then(|value| serde_json::from_str::>(value).ok()) + .filter(|genres| !genres.is_empty()) + .unwrap_or_default(); + let release_year = link.release_year; + let logo_url = link.logo_url.clone(); + let rating = link.rating; + let content_rating = link.content_rating.clone(); + + LinkedMetadataPresentation { + tagline, + overview, + genres, + release_year, + media_type: link.media_type.clone(), + poster_available: link.cached_artwork_path.is_some() || link.artwork_url.is_some(), + backdrop_available: link.cached_backdrop_path.is_some() || link.backdrop_url.is_some(), + logo_url, + rating, + content_rating, + trailer_title: None, + trailer_url: None, + theme_song_url: None, + } +} + +/// Extract merged presentation metadata from ordered stored links. +pub fn presentation_from_metadata_links( + conn: &mut SqliteConnection, + links: &[ItemMetadataLink], +) -> Result { + let extras_by_link_id = + metadata_extras_by_link_id(conn, &links.iter().map(|link| link.id).collect::>())?; + let mut merged = LinkedMetadataPresentation::default(); + for link in links { + let mut presentation = presentation_from_metadata_link(link); + if let Some(extras) = extras_by_link_id.get(&link.id) { + if let Some((title, url)) = + preferred_metadata_extra(extras, METADATA_EXTRA_TYPE_TRAILER) + { + presentation.trailer_title = title; + presentation.trailer_url = Some(url); + } + if let Some((_, url)) = preferred_metadata_extra(extras, METADATA_EXTRA_TYPE_THEME_SONG) + { + presentation.theme_song_url = Some(url); + } + } + if merged.tagline.is_none() { + merged.tagline = presentation.tagline; + } + if merged.overview.is_none() { + merged.overview = presentation.overview; + } + if merged.genres.is_empty() && !presentation.genres.is_empty() { + merged.genres = presentation.genres; + } + if merged.release_year.is_none() { + merged.release_year = presentation.release_year; + } + if merged.media_type.is_none() { + merged.media_type = presentation.media_type; + } + merged.poster_available |= presentation.poster_available; + merged.backdrop_available |= presentation.backdrop_available; + if merged.logo_url.is_none() { + merged.logo_url = presentation.logo_url; + } + if merged.rating.is_none() { + merged.rating = presentation.rating; + } + if merged.content_rating.is_none() { + merged.content_rating = presentation.content_rating; + } + if merged.trailer_title.is_none() { + merged.trailer_title = presentation.trailer_title; + } + if merged.trailer_url.is_none() { + merged.trailer_url = presentation.trailer_url; + } + if merged.theme_song_url.is_none() { + merged.theme_song_url = presentation.theme_song_url; + } + } + + Ok(merged) +} + +/// Extract ordered, deduplicated external media extras from stored metadata links. +pub fn metadata_extras_from_metadata_links( + conn: &mut SqliteConnection, + links: &[ItemMetadataLink], +) -> Result, diesel::result::Error> { + let extras_by_link_id = + metadata_extras_by_link_id(conn, &links.iter().map(|link| link.id).collect::>())?; + let mut seen = HashSet::new(); + let mut extras = Vec::new(); + + for link in links { + let Some(link_extras) = extras_by_link_id.get(&link.id) else { + continue; + }; + + for (extra, media) in link_extras { + let url = media.url.trim(); + if url.is_empty() { + continue; + } + if !seen.insert((extra.extra_type.clone(), url.to_string())) { + continue; + } + + extras.push(LinkedMetadataExtra { + extra_type: extra.extra_type.clone(), + title: extra.title.clone().or_else(|| media.title.clone()), + url: url.to_string(), + duration_seconds: media.duration_seconds, + thumbnail_url: media.thumbnail_url.clone(), + }); + } + } + + Ok(extras) +} + +fn metadata_extras_by_link_id( + conn: &mut SqliteConnection, + link_ids: &[i32], +) -> Result>, diesel::result::Error> { + use crate::db::schema::external_media::dsl as media_dsl; + use crate::db::schema::metadata_extras::dsl as extras_dsl; + + if link_ids.is_empty() { + return Ok(HashMap::new()); + } + + let link_ids = link_ids.iter().copied().map(Some).collect::>(); + let rows = extras_dsl::metadata_extras + .inner_join(media_dsl::external_media) + .filter(extras_dsl::metadata_link_id.eq_any(link_ids)) + .order(( + extras_dsl::metadata_link_id.asc(), + extras_dsl::sort_order.asc(), + extras_dsl::id.asc(), + )) + .select((MetadataExtra::as_select(), ExternalMedia::as_select())) + .load::<(MetadataExtra, ExternalMedia)>(conn)?; + + let mut by_link_id = HashMap::new(); + for (extra, media) in rows { + let Some(link_id) = extra.metadata_link_id else { + continue; + }; + by_link_id + .entry(link_id) + .or_insert_with(Vec::new) + .push((extra, media)); + } + Ok(by_link_id) +} + +fn preferred_metadata_extra( + extras: &[(MetadataExtra, ExternalMedia)], + extra_type: &str, +) -> Option<(Option, String)> { + extras + .iter() + .filter(|(extra, _)| extra.extra_type == extra_type) + .map(|(extra, media)| { + ( + extra.title.clone().or_else(|| media.title.clone()), + media.url.clone(), + ) + }) + .next() +} + +/// Persist stored metadata payload and cached artwork into the managed item asset structure. +pub async fn persist_item_metadata_assets( + snapshot: &StoredMetadataSnapshot, + _item_id: i32, + data_dir: &str, +) -> Result<(Option, Option, Option), String> { + persist_item_metadata_assets_with_logo(snapshot, _item_id, data_dir, None).await +} + +/// Persist stored metadata artwork with an optional logo URL already loaded from the database. +pub async fn persist_item_metadata_assets_with_logo( + snapshot: &StoredMetadataSnapshot, + _item_id: i32, + data_dir: &str, + logo_url_override: Option<&str>, +) -> Result<(Option, Option, Option), String> { + let item_dir = managed_metadata_asset_dir( + data_dir, + snapshot.provider_id.clone(), + &snapshot.external_id, + snapshot.media_type.as_deref(), + &snapshot.locale_key, + ); + fs::create_dir_all(&item_dir).map_err(|error| error.to_string())?; + + let logo_url = logo_url_override + .map(str::to_string) + .or_else(|| provider_metadata_details(snapshot).logo_url); + + let poster_cache_key = format!("{}_poster", snapshot.provider_id.as_storage_value()); + let poster_path = if let Some(url) = &snapshot.artwork_url { + try_cache_item_artwork(url, &item_dir, &poster_cache_key).await + } else { + purge_stale_cached_artwork_files(&item_dir, &poster_cache_key, None)?; + None + }; + let backdrop_cache_key = format!("{}_backdrop", snapshot.provider_id.as_storage_value()); + let backdrop_path = if let Some(url) = &snapshot.backdrop_url { + try_cache_item_artwork(url, &item_dir, &backdrop_cache_key).await + } else { + purge_stale_cached_artwork_files(&item_dir, &backdrop_cache_key, None)?; + None + }; + let logo_cache_key = format!("{}_logo", snapshot.provider_id.as_storage_value()); + let logo_path = if let Some(url) = logo_url { + try_cache_item_artwork(&url, &item_dir, &logo_cache_key).await + } else { + purge_stale_cached_artwork_files(&item_dir, &logo_cache_key, None)?; + None + }; + + Ok((poster_path, backdrop_path, logo_path)) +} + +/// Cache person artwork referenced by a metadata payload and return a snapshot with cached paths +/// embedded. +pub async fn persist_metadata_people_assets( + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result { + let registry = MetadataRegistry::new(); + let Some(provider) = registry.provider(&snapshot.provider_id) else { + return Ok(snapshot.clone()); + }; + provider.cache_person_assets(snapshot, data_dir).await +} + +/// Return the deterministic provider-uuid based asset path for metadata payloads. +pub fn managed_metadata_asset_dir( + data_dir: &str, + provider_id: MetadataProviderId, + external_id: &str, + media_type: Option<&str>, + locale_key: &str, +) -> PathBuf { + let registry = MetadataRegistry::new(); + let item_kind = registry + .provider(&provider_id) + .map(|provider| provider.metadata_item_kind(media_type)) + .unwrap_or(MetadataItemKind::Item); + let uuid = metadata_asset_uuid(provider_id, external_id, locale_key); + let full_hash = Sha256::digest(uuid.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let (shard, directory_name) = full_hash.split_at(1); + + Path::new(data_dir) + .join("metadata") + .join(metadata_asset_type_directory(item_kind)) + .join(shard) + .join(directory_name) +} + +/// Convert an absolute managed asset path into a data-dir-relative database value. +pub fn metadata_asset_db_path( + data_dir: &str, + path: &Path, +) -> String { + let data_dir = Path::new(data_dir); + let relative_path = path.strip_prefix(data_dir).unwrap_or(path); + relative_path.to_string_lossy().replace('\\', "/") +} + +/// Resolve a stored metadata asset path against the current data directory. +pub fn resolve_metadata_asset_db_path( + data_dir: &str, + stored_path: &str, +) -> PathBuf { + let path = PathBuf::from(stored_path); + if path.is_absolute() { path } else { Path::new(data_dir).join(path) } +} + +/// Return the stable provider UUID used to derive metadata paths. +pub fn metadata_asset_uuid( + provider_id: MetadataProviderId, + external_id: &str, + locale_key: &str, +) -> String { + format!( + "{}:{}:{}", + provider_id.as_storage_value(), + external_id.trim(), + normalize_locale_key(locale_key) + ) +} + +fn metadata_response_cache_dir(data_dir: &str) -> PathBuf { + Path::new(data_dir) + .join("metadata") + .join("cache") + .join("responses") +} + +pub(crate) fn metadata_response_cache_key( + provider_id: &MetadataProviderId, + kind: &str, + parts: &[&str], +) -> String { + let mut hasher = Sha256::new(); + hasher.update(provider_id.as_storage_value().as_bytes()); + hasher.update([0]); + hasher.update(kind.as_bytes()); + for part in parts { + hasher.update([0]); + hasher.update(part.trim().as_bytes()); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn metadata_response_cache_path(cache_key: &str) -> PathBuf { + let data_dir = crate::config::current_settings().general.data_dir; + let (shard, file_stem) = cache_key.split_at(1); + metadata_response_cache_dir(&data_dir) + .join(shard) + .join(format!("{file_stem}.json")) +} + +fn read_metadata_snapshot_cache(cache_key: &str) -> Option { + let contents = read_metadata_response_cache_text(cache_key)?; + let entry = serde_json::from_str::(&contents).ok()?; + Some(entry.snapshot) +} + +fn write_metadata_snapshot_cache( + cache_key: &str, + snapshot: &StoredMetadataSnapshot, +) { + let entry = MetadataSnapshotCacheEntry { + created_at: current_timestamp(), + snapshot: snapshot.clone(), + }; + if let Ok(contents) = serde_json::to_string(&entry) { + write_metadata_response_cache_text(cache_key, &contents); + } +} + +pub(crate) fn read_metadata_response_cache_text(cache_key: &str) -> Option { + let path = metadata_response_cache_path(cache_key); + let contents = fs::read_to_string(&path).ok()?; + let created_at = fs::metadata(&path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.elapsed().ok()) + .and_then(|elapsed| i64::try_from(elapsed.as_secs()).ok()) + .map(|age| current_timestamp().saturating_sub(age)) + .unwrap_or_else(current_timestamp); + let age = current_timestamp().saturating_sub(created_at); + if age > METADATA_RESPONSE_CACHE_TTL_SECONDS { + let _ = fs::remove_file(path); + return None; + } + Some(contents) +} + +pub(crate) fn write_metadata_response_cache_text( + cache_key: &str, + contents: &str, +) { + let path = metadata_response_cache_path(cache_key); + let Some(parent) = path.parent() else { + return; + }; + if fs::create_dir_all(parent).is_err() { + return; + } + let _ = fs::write(path, contents); +} + +fn count_files_recursive(path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + let mut count = 0; + for entry in fs::read_dir(path).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let entry_path = entry.path(); + if entry_path.is_dir() { + count += count_files_recursive(&entry_path)?; + } else { + count += 1; + } + } + Ok(count) +} + +fn metadata_asset_type_directory(item_kind: MetadataItemKind) -> &'static str { + item_kind.asset_directory() +} + +/// Persist a cached artwork path for a metadata link. +pub fn update_cached_artwork_path( + conn: &mut SqliteConnection, + link_id: i32, + kind: ArtworkKind, + cache_path: &Path, + data_dir: &str, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::item_metadata_links::dsl as metadata_links_dsl; + configure_sqlite_connection(conn)?; + let stored_cache_path = metadata_asset_db_path(data_dir, cache_path); + retry_sqlite_write(|| { + match kind { + ArtworkKind::Poster => { + diesel::update( + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::id.eq(link_id)), + ) + .set(metadata_links_dsl::cached_artwork_path.eq(stored_cache_path.clone())) + .execute(conn)?; + } + ArtworkKind::Backdrop => { + diesel::update( + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::id.eq(link_id)), + ) + .set(metadata_links_dsl::cached_backdrop_path.eq(stored_cache_path.clone())) + .execute(conn)?; + } + ArtworkKind::Logo => { + diesel::update( + metadata_links_dsl::item_metadata_links + .filter(metadata_links_dsl::id.eq(link_id)), + ) + .set(metadata_links_dsl::cached_logo_path.eq(stored_cache_path.clone())) + .execute(conn)?; + } + } + + Ok(()) + }) +} + +/// Poster, backdrop, or title logo artwork kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtworkKind { + /// Poster or cover art. + Poster, + /// Background or hero artwork. + Backdrop, + /// Title logo artwork. + Logo, +} + +impl ArtworkKind { + /// Parse an artwork kind from a query parameter. + pub fn from_query_value(value: Option<&str>) -> Self { + match value.unwrap_or_default() { + "backdrop" => ArtworkKind::Backdrop, + "logo" => ArtworkKind::Logo, + _ => ArtworkKind::Poster, + } + } +} + +/// Download and cache one artwork asset to disk. +pub async fn cache_artwork( + url: &str, + cache_dir: &Path, + cache_key: &str, +) -> Result { + fs::create_dir_all(cache_dir).map_err(|error| error.to_string())?; + + let cache_path = expected_artwork_cache_path(url, cache_dir, cache_key); + let file_name = cache_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "Invalid artwork cache file name".to_string())? + .to_string(); + purge_stale_cached_artwork_files(cache_dir, cache_key, Some(&file_name))?; + if cache_path.is_file() { + return Ok(cache_path); + } + + let bytes = reqwest::get(url) + .await + .map_err(|error| error.to_string())? + .bytes() + .await + .map_err(|error| error.to_string())?; + fs::write(&cache_path, bytes).map_err(|error| error.to_string())?; + + Ok(cache_path) +} + +/// Return the deterministic on-disk path for a cached artwork URL. +pub fn expected_artwork_cache_path( + url: &str, + cache_dir: &Path, + cache_key: &str, +) -> PathBuf { + let cache_file_name = format!( + "{}-{:016x}.{}", + sanitize_cache_key(cache_key), + stable_artwork_url_hash(url), + artwork_url_extension(url) + ); + cache_dir.join(cache_file_name) +} + +/// Provider-side season and episode identifiers resolved for one show descendant. +#[derive(Debug, Clone)] +pub struct ProviderDescendantTarget { + /// Local season number for matching persisted items. + pub season_number: i32, + /// Local episode number for matching persisted items. + pub episode_number: i32, + /// Provider-side season identifier. + pub season_external_id: String, + /// Provider-side episode identifier. + pub episode_external_id: String, +} + +fn provider_settings( + settings: &MetadataSettings, + provider_id: MetadataProviderId, +) -> Result { + let mut provider = settings + .providers + .iter() + .find(|provider| provider.id == provider_id) + .cloned() + .ok_or_else(|| "is not configured.".to_string())?; + + let requires_api_key = MetadataRegistry::new() + .provider(&provider_id) + .map(|provider| provider.descriptor().requires_api_key) + .unwrap_or(true); + if requires_api_key { + resolve_metadata_provider_api_key(&mut provider)?; + } + let api_key_missing = provider + .api_key + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty(); + if requires_api_key && api_key_missing { + return Err("requires an API key but none is configured.".into()); + } + + Ok(provider) +} + +fn configured_provider_language( + settings: &MetadataSettings, + provider_id: &MetadataProviderId, +) -> String { + settings + .providers + .iter() + .find(|provider| provider.id == *provider_id) + .map(|provider| provider.language.clone()) + .unwrap_or_else(|| DEFAULT_METADATA_LOCALE.to_string()) +} + +fn format_payload_snippet(payload: &str) -> String { + let snippet = payload.split_whitespace().collect::>().join(" "); + if snippet.is_empty() { + return String::new(); + } + + let truncated = if snippet.chars().count() > 180 { + let prefix = snippet.chars().take(180).collect::(); + format!("{}…", prefix) + } else { + snippet + }; + format!(" | response: {}", truncated) +} + +fn retry_sqlite_write(mut operation: F) -> Result +where + F: FnMut() -> Result, +{ + let mut attempts = 0; + loop { + match operation() { + Ok(value) => return Ok(value), + Err(error) if is_sqlite_locked_error(&error) && attempts < 4 => { + attempts += 1; + let backoff_ms = 25_u64.saturating_mul(2_u64.saturating_pow(attempts)); + std::thread::sleep(Duration::from_millis(backoff_ms)); + } + Err(error) => return Err(error), + } + } +} + +fn is_sqlite_locked_error(error: &diesel::result::Error) -> bool { + match error { + diesel::result::Error::DatabaseError(_, info) => info + .message() + .to_ascii_lowercase() + .contains("database is locked"), + _ => error + .to_string() + .to_ascii_lowercase() + .contains("database is locked"), + } +} + +fn to_item_metadata_summary(link: ItemMetadataLink) -> ItemMetadataSummary { + ItemMetadataSummary { + id: link.id, + provider_id: metadata_provider_id_from_db(&link.provider_id), + external_id: link.external_id, + title: link.title, + overview: link.overview, + artwork_url: link.artwork_url, + backdrop_url: link.backdrop_url, + release_year: link.release_year, + media_type: link.media_type, + relation_kind: link.relation_kind, + match_state: link.match_state, + logo_url: link.logo_url, + cached_logo_path: link.cached_logo_path, + genres: link + .genres_json + .as_deref() + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(), + people: Vec::new(), + rating: link.rating, + content_rating: link.content_rating, + trailer_title: None, + trailer_url: None, + theme_song_url: None, + locale_key: link.locale_key, + provider_locale_key: link.provider_locale_key, + cached_artwork_path: link.cached_artwork_path, + cached_backdrop_path: link.cached_backdrop_path, + refresh_state: link.refresh_state, + last_refreshed_at: link.last_refreshed_at, + next_refresh_at: link.next_refresh_at, + refresh_error: link.refresh_error, + updated_at: link.updated_at, + } +} + +fn to_item_metadata_summary_with_people( + conn: &mut SqliteConnection, + link: ItemMetadataLink, +) -> Result { + use crate::db::schema::metadata_people::dsl as people_dsl; + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + let people = credit_dsl::metadata_person_credits + .inner_join(people_dsl::metadata_people) + .filter(credit_dsl::metadata_link_id.eq(link.id)) + .order(credit_dsl::sort_order.asc()) + .select(( + MetadataPersonCredit::as_select(), + MetadataPerson::as_select(), + )) + .load::<(MetadataPersonCredit, MetadataPerson)>(conn)? + .into_iter() + .map(to_item_metadata_person_summary) + .collect(); + let extras_by_link_id = metadata_extras_by_link_id(conn, &[link.id])?; + let link_id = link.id; + let mut summary = to_item_metadata_summary(link); + summary.people = people; + if let Some(extras) = extras_by_link_id.get(&link_id) { + if let Some((title, url)) = preferred_metadata_extra(extras, METADATA_EXTRA_TYPE_TRAILER) { + summary.trailer_title = title; + summary.trailer_url = Some(url); + } + if let Some((_, url)) = preferred_metadata_extra(extras, METADATA_EXTRA_TYPE_THEME_SONG) { + summary.theme_song_url = Some(url); + } + } + Ok(summary) +} + +fn to_item_metadata_person_summary( + (credit, person): (MetadataPersonCredit, MetadataPerson) +) -> ItemMetadataPersonSummary { + ItemMetadataPersonSummary { + id: credit.id, + person_id: person.id, + external_id: person.external_id, + locale_key: person.locale_key, + name: person.name, + role: credit.role, + department: credit.department, + character_name: credit.character_name, + profile_url: person.profile_url, + image_url: person.image_url, + cached_image_path: person.cached_image_path, + sort_order: credit.sort_order, + } +} + +fn to_metadata_person_summary(person: MetadataPerson) -> MetadataPersonSummary { + MetadataPersonSummary { + id: person.id, + provider_id: metadata_provider_id_from_db(&person.provider_id), + external_id: person.external_id, + locale_key: person.locale_key, + name: person.name, + known_for: person + .known_for_json + .as_deref() + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(), + biography: person.biography, + gender: person.gender, + birthday: person.birthday, + deathday: person.deathday, + birth_place: person.birth_place, + profile_url: person.profile_url, + image_url: person.image_url, + cached_image_path: person.cached_image_path, + updated_at: person.updated_at, + } +} + +pub(crate) async fn try_cache_item_artwork( + url: &str, + item_dir: &Path, + cache_key: &str, +) -> Option { + match cache_artwork(url, item_dir, cache_key).await { + Ok(path) => Some(path), + Err(error) => { + log::warn!( + "Failed to cache managed artwork asset from {}: {}", + url, + error + ); + None + } + } +} + +fn provider_display_name(provider_id: &MetadataProviderId) -> String { + let registry = MetadataRegistry::new(); + registry + .provider(provider_id) + .map(|provider| provider.descriptor().display_name) + .unwrap_or_else(|| provider_id.as_storage_value().to_string()) +} + +fn extract_release_year(value: Option) -> Option { + value + .as_deref() + .and_then(|value| value.split('-').next()) + .and_then(|value| value.parse::().ok()) +} + +fn parse_movie_name( + relative_path: &str, + display_title: &str, +) -> ParsedMovieName { + let relative_path = relative_path.replace('\\', "/"); + let path = Path::new(&relative_path); + let file_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(display_title); + let parent_name = path + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + let preferred_source = + if parent_name.eq_ignore_ascii_case(file_stem) || has_title_and_year(parent_name) { + parent_name + } else { + file_stem + }; + + let tag_values = BRACED_TAG_REGEX + .captures_iter(preferred_source) + .chain(BRACED_TAG_REGEX.captures_iter(file_stem)) + .filter_map(|captures| { + captures + .get(1) + .map(|value| value.as_str().trim().to_string()) + }) + .collect::>(); + let provider_ids = provider_tag_values(&tag_values); + let year = movie_year_from_name(preferred_source) + .or_else(|| movie_year_from_name(file_stem)) + .or_else(|| movie_year_from_name(display_title)); + + let cleaned = cleanup_movie_title(preferred_source); + let fallback = cleanup_movie_title(display_title); + ParsedMovieName { + title: if cleaned.is_empty() { fallback } else { cleaned }, + year, + provider_ids, + } +} + +fn movie_year_from_name(value: &str) -> Option { + PARENTHETICAL_YEAR_REGEX + .captures(value) + .or_else(|| { + YEAR_REGEX.captures(value).filter(|captures| { + captures + .get(1) + .map(|year| !value[..year.start()].trim().is_empty()) + .unwrap_or(false) + }) + }) + .and_then(|captures| captures.get(1)) + .and_then(|value| value.as_str().parse::().ok()) +} + +fn has_title_and_year(value: &str) -> bool { + movie_year_from_name(value).is_some() +} + +fn provider_tag_values(tags: &[String]) -> HashMap { + tags.iter() + .flat_map(|tag| tag.split(':')) + .filter_map(provider_tag_value) + .collect() +} + +fn provider_tag_value(part: &str) -> Option<(String, String)> { + let part = part.trim(); + for separator in ["-", ":", "_"] { + let Some((provider, external_id)) = part.split_once(separator) else { + continue; + }; + let provider = provider.trim().to_ascii_lowercase(); + let external_id = external_id.trim().to_string(); + if !provider.is_empty() + && !external_id.is_empty() + && !external_id.chars().any(char::is_whitespace) + { + return Some((provider, external_id)); + } + } + None +} + +fn show_search_query( + relative_path: &str, + display_title: &str, +) -> String { + let normalized_path = relative_path.replace('\\', "/"); + let first_segment = normalized_path + .split('/') + .find(|segment| !segment.trim().is_empty()) + .unwrap_or_default() + .to_string(); + let folder_name = Path::new(&normalized_path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + + [ + display_title.to_string(), + first_segment, + folder_name, + ] + .into_iter() + .map(|value| cleanup_movie_title(&value)) + .find(|value| !value.trim().is_empty()) + .unwrap_or_default() +} + +fn cleanup_movie_title(value: &str) -> String { + let without_tags = BRACED_TAG_REGEX.replace_all(value, " "); + let without_split_suffix = SPLIT_SUFFIX_REGEX.replace(&without_tags, " "); + let without_parenthetical_year = PARENTHETICAL_YEAR_REGEX.replace(&without_split_suffix, " "); + let mut normalized = without_parenthetical_year.replace(['.', '_'], " "); + if let Some(year_match) = YEAR_REGEX.find(&normalized) { + if !normalized[..year_match.start()].trim().is_empty() { + normalized = normalized[..year_match.start()].to_string(); + } + } + normalized = TITLE_COLON_DASH_REGEX + .replace_all(&normalized, ": ") + .to_string(); + normalized = NOISE_TOKEN_REGEX.replace_all(&normalized, " ").to_string(); + + normalized + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_string() +} + +fn movie_match_score( + parsed: &ParsedMovieName, + result: &MetadataSearchResult, +) -> f64 { + let candidate_title = cleanup_movie_title(&result.title); + if candidate_title.is_empty() || parsed.title.is_empty() { + return 0.0; + } + + let mut score = normalized_levenshtein( + &parsed.title.to_ascii_lowercase(), + &candidate_title.to_ascii_lowercase(), + ); + if let Some(expected_year) = parsed.year { + match result.release_year { + Some(candidate_year) if candidate_year == expected_year => { + score += 0.18; + } + Some(candidate_year) if (candidate_year - expected_year).abs() == 1 => { + score += 0.05; + } + Some(_) => { + score -= 0.2; + } + None => { + score -= 0.04; + } + } + } + + score.clamp(0.0, 1.0) +} + +fn sanitize_cache_key(value: &str) -> String { + value + .chars() + .map(|character| if character.is_ascii_alphanumeric() { character } else { '_' }) + .collect() +} + +fn artwork_url_extension(url: &str) -> String { + let normalized = url.split(['?', '#']).next().unwrap_or(url); + Path::new(normalized) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("jpg") + .to_ascii_lowercase() +} + +pub(crate) fn image_format_preference_rank(url: &str) -> u8 { + match artwork_url_extension(url).as_str() { + "svg" => 2, + "png" => 1, + _ => 0, + } +} + +pub(crate) fn preferred_image_url_by_format(urls: I) -> Option +where + I: IntoIterator, + S: AsRef, +{ + let mut best = None::<(u8, String)>; + for url in urls { + let url = url.as_ref().trim(); + if url.is_empty() { + continue; + } + let rank = image_format_preference_rank(url); + if best + .as_ref() + .map(|(best_rank, _)| rank > *best_rank) + .unwrap_or(true) + { + best = Some((rank, url.to_string())); + if rank == 2 { + break; + } + } + } + best.map(|(_, url)| url) +} + +fn stable_artwork_url_hash(url: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + hasher.finish() +} + +fn purge_stale_cached_artwork_files( + cache_dir: &Path, + cache_key: &str, + keep_file_name: Option<&str>, +) -> Result<(), String> { + if !cache_dir.is_dir() { + return Ok(()); + } + + let prefix = sanitize_cache_key(cache_key); + for entry in fs::read_dir(cache_dir).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if !file_name.starts_with(&prefix) { + continue; + } + if keep_file_name == Some(file_name) { + continue; + } + + fs::remove_file(path).map_err(|error| error.to_string())?; + } + + Ok(()) +} + +fn root_media_item_id( + item_id: i32, + items_by_id: &HashMap, +) -> Option { + let mut current_id = item_id; + let mut seen = HashSet::new(); + + loop { + let item = items_by_id.get(¤t_id)?; + let Some(parent_id) = item.parent_id else { + return Some(item.id); + }; + if !seen.insert(parent_id) { + return Some(item.id); + } + current_id = parent_id; + } +} + +fn sync_item_metadata_collections( + conn: &mut SqliteConnection, + metadata_link_id: i32, + media_item_id: i32, + snapshot: &StoredMetadataSnapshot, + details: &ProviderMetadataDetails, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::metadata_collection_items::dsl as collection_items_dsl; + use crate::db::schema::metadata_collections::dsl as collections_dsl; + + let provider_id = snapshot.provider_id.as_storage_value().to_string(); + let existing_collection_ids = collections_dsl::metadata_collections + .filter(collections_dsl::provider_id.eq(&provider_id)) + .filter(collections_dsl::relation_kind.eq("primary")) + .filter(collections_dsl::locale_key.eq(&snapshot.locale_key)) + .select(collections_dsl::id) + .load::(conn)?; + if !existing_collection_ids.is_empty() { + diesel::delete( + collection_items_dsl::metadata_collection_items + .filter(collection_items_dsl::media_item_id.eq(media_item_id)) + .filter(collection_items_dsl::collection_id.eq_any(existing_collection_ids)), + ) + .execute(conn)?; + } + + if details.collections.is_empty() { + return Ok(()); + } + + let mut seen_collection_ids = HashSet::new(); + let now = current_timestamp(); + for collection in details.collections.iter().cloned() { + let collection_external_id = collection.external_id.clone(); + let collection_id = upsert_metadata_collection( + conn, + MetadataCollectionUpsert { + provider_id: &provider_id, + source_provider_id: &provider_id, + source_external_id: &collection_external_id, + relation_kind: "primary", + locale_key: &snapshot.locale_key, + provider_locale_key: snapshot.provider_locale_key.clone(), + collection, + now, + }, + )?; + if !seen_collection_ids.insert(collection_id) { + continue; + } + let row = NewMetadataCollectionItem { + collection_id, + media_item_id, + metadata_link_id, + updated_at: Some(now), + }; + diesel::insert_into(collection_items_dsl::metadata_collection_items) + .values(&row) + .on_conflict(( + collection_items_dsl::collection_id, + collection_items_dsl::media_item_id, + )) + .do_update() + .set(( + collection_items_dsl::metadata_link_id.eq(metadata_link_id), + collection_items_dsl::updated_at.eq(Some(now)), + )) + .execute(conn)?; + } + + Ok(()) +} + +struct MetadataCollectionUpsert<'a> { + provider_id: &'a str, + source_provider_id: &'a str, + source_external_id: &'a str, + relation_kind: &'a str, + locale_key: &'a str, + provider_locale_key: Option, + collection: ProviderMetadataCollection, + now: i64, +} + +fn upsert_metadata_collection( + conn: &mut SqliteConnection, + upsert: MetadataCollectionUpsert<'_>, +) -> Result { + let MetadataCollectionUpsert { + provider_id, + source_provider_id, + source_external_id, + relation_kind, + locale_key, + provider_locale_key, + collection, + now, + } = upsert; + use crate::db::schema::metadata_collections::dsl as collections_dsl; + + let theme_song_url = collection.theme_song_url.clone(); + let existing = collections_dsl::metadata_collections + .filter(collections_dsl::provider_id.eq(provider_id)) + .filter(collections_dsl::external_id.eq(&collection.external_id)) + .filter(collections_dsl::relation_kind.eq(relation_kind)) + .filter(collections_dsl::locale_key.eq(locale_key)) + .select(MetadataCollection::as_select()) + .first::(conn) + .optional()?; + + let collection_name = collection + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let should_retain_existing_name = relation_kind == "primary" && collection_name.is_none(); + let payload = NewMetadataCollection { + provider_id: provider_id.to_string(), + external_id: collection.external_id.clone(), + source_provider_id: source_provider_id.to_string(), + source_external_id: source_external_id.to_string(), + relation_kind: relation_kind.to_string(), + locale_key: locale_key.to_string(), + provider_locale_key, + name: if should_retain_existing_name { + existing.as_ref().and_then(|row| row.name.clone()) + } else { + collection_name + }, + overview: collection + .overview + .or_else(|| existing.as_ref().and_then(|row| row.overview.clone())), + artwork_url: collection + .artwork_url + .or_else(|| existing.as_ref().and_then(|row| row.artwork_url.clone())), + backdrop_url: collection + .backdrop_url + .or_else(|| existing.as_ref().and_then(|row| row.backdrop_url.clone())), + updated_at: Some(now), + }; + + let collection_id = if let Some(existing) = existing { + diesel::update( + collections_dsl::metadata_collections.filter(collections_dsl::id.eq(existing.id)), + ) + .set(&payload) + .execute(conn)?; + existing.id + } else { + diesel::insert_into(collections_dsl::metadata_collections) + .values(&payload) + .on_conflict(( + collections_dsl::provider_id, + collections_dsl::external_id, + collections_dsl::relation_kind, + collections_dsl::locale_key, + )) + .do_update() + .set(&payload) + .execute(conn)?; + collections_dsl::metadata_collections + .filter(collections_dsl::provider_id.eq(provider_id)) + .filter(collections_dsl::external_id.eq(&collection.external_id)) + .filter(collections_dsl::relation_kind.eq(relation_kind)) + .filter(collections_dsl::locale_key.eq(locale_key)) + .select(collections_dsl::id) + .first::(conn)? + }; + + if let Some(theme_song_url) = theme_song_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sync_collection_theme_song_extra(conn, collection_id, theme_song_url, now)?; + } + + Ok(collection_id) +} + +fn sync_item_metadata_external_ids( + conn: &mut SqliteConnection, + metadata_link_id: i32, + snapshot: &StoredMetadataSnapshot, + details: &ProviderMetadataDetails, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::item_metadata_external_ids::dsl as external_ids_dsl; + + diesel::delete( + external_ids_dsl::item_metadata_external_ids + .filter(external_ids_dsl::metadata_link_id.eq(metadata_link_id)), + ) + .execute(conn)?; + + let mut rows = Vec::new(); + let mut seen = HashSet::new(); + let now = current_timestamp(); + + let primary_external_id = ProviderExternalId { + source: snapshot.provider_id.as_storage_value().to_string(), + external_id: snapshot.external_id.clone(), + }; + for external_id in details + .external_ids + .iter() + .chain(std::iter::once(&primary_external_id)) + { + let Some(source) = normalize_external_id_source(&external_id.source) else { + continue; + }; + let external_id = external_id.external_id.trim().to_string(); + if source.is_empty() || external_id.is_empty() || !seen.insert(source.clone()) { + continue; + } + rows.push(NewItemMetadataExternalId { + metadata_link_id, + source, + external_id, + updated_at: Some(now), + }); + } + + for row in rows { + diesel::insert_into(external_ids_dsl::item_metadata_external_ids) + .values(&row) + .on_conflict((external_ids_dsl::metadata_link_id, external_ids_dsl::source)) + .do_update() + .set(( + external_ids_dsl::external_id + .eq(diesel::upsert::excluded(external_ids_dsl::external_id)), + external_ids_dsl::updated_at + .eq(diesel::upsert::excluded(external_ids_dsl::updated_at)), + )) + .execute(conn)?; + } + + Ok(()) +} + +fn sync_item_metadata_people( + conn: &mut SqliteConnection, + metadata_link_id: i32, + snapshot: &StoredMetadataSnapshot, + details: &ProviderMetadataDetails, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::item_metadata_people::dsl as people_dsl; + use crate::db::schema::metadata_people::dsl as normalized_people_dsl; + use crate::db::schema::metadata_person_credits::dsl as credit_dsl; + + diesel::delete( + people_dsl::item_metadata_people.filter(people_dsl::metadata_link_id.eq(metadata_link_id)), + ) + .execute(conn)?; + diesel::delete( + credit_dsl::metadata_person_credits + .filter(credit_dsl::metadata_link_id.eq(metadata_link_id)), + ) + .execute(conn)?; + + let people = details.people.clone(); + if people.is_empty() { + return Ok(()); + } + + let rows = people + .iter() + .cloned() + .map(|person| NewItemMetadataPerson { + metadata_link_id, + external_id: person.external_id, + name: person.name, + role: person.role, + department: person.department, + character_name: person.character_name, + profile_url: person.profile_url, + image_url: person.image_url, + sort_order: person.sort_order, + }) + .collect::>(); + + diesel::insert_into(people_dsl::item_metadata_people) + .values(&rows) + .execute(conn)?; + + for person in people { + let provider_id = snapshot.provider_id.as_storage_value().to_string(); + let existing_person = find_metadata_person_for_provider_person( + conn, + &provider_id, + &snapshot.locale_key, + &person, + )?; + let normalized_person = if let Some(existing_person) = existing_person { + let payload = merged_metadata_person_payload(&existing_person, &person); + diesel::update( + normalized_people_dsl::metadata_people + .filter(normalized_people_dsl::id.eq(existing_person.id)), + ) + .set(&payload) + .execute(conn)?; + normalized_people_dsl::metadata_people + .filter(normalized_people_dsl::id.eq(existing_person.id)) + .select(MetadataPerson::as_select()) + .first(conn)? + } else { + let payload = new_metadata_person_payload( + provider_id.clone(), + snapshot.locale_key.clone(), + &person, + ); + diesel::insert_into(normalized_people_dsl::metadata_people) + .values(&payload) + .on_conflict_do_nothing() + .execute(conn)?; + find_metadata_person_for_provider_person( + conn, + &provider_id, + &snapshot.locale_key, + &person, + )? + .ok_or(diesel::result::Error::NotFound)? + }; + + sync_metadata_person_external_ids(conn, normalized_person.id, &provider_id, &person)?; + + diesel::insert_into(credit_dsl::metadata_person_credits) + .values(&NewMetadataPersonCredit { + metadata_link_id, + person_id: normalized_person.id, + role: person.role, + department: person.department, + character_name: person.character_name, + sort_order: person.sort_order, + }) + .on_conflict(( + credit_dsl::metadata_link_id, + credit_dsl::person_id, + credit_dsl::role, + credit_dsl::character_name, + )) + .do_update() + .set(( + credit_dsl::department.eq(diesel::upsert::excluded(credit_dsl::department)), + credit_dsl::sort_order.eq(diesel::upsert::excluded(credit_dsl::sort_order)), + )) + .execute(conn)?; + } + + Ok(()) +} + +fn sync_metadata_person_external_ids( + conn: &mut SqliteConnection, + person_id: i32, + provider_id: &str, + person: &ProviderMetadataPerson, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::metadata_person_external_ids::dsl as external_ids_dsl; + + let now = current_timestamp(); + let primary_external_id = person + .external_id + .as_ref() + .map(|external_id| ProviderExternalId { + source: provider_id.to_string(), + external_id: external_id.clone(), + }); + let mut seen = HashSet::new(); + let mut rows = Vec::new(); + + for external_id in person.external_ids.iter().chain(primary_external_id.iter()) { + let Some(source) = normalize_external_id_source(&external_id.source) else { + continue; + }; + let external_id = external_id.external_id.trim().to_string(); + if external_id.is_empty() || !seen.insert(source.clone()) { + continue; + } + rows.push(NewMetadataPersonExternalId { + person_id, + source, + external_id, + updated_at: Some(now), + }); + } + + for row in rows { + diesel::insert_into(external_ids_dsl::metadata_person_external_ids) + .values(&row) + .on_conflict((external_ids_dsl::person_id, external_ids_dsl::source)) + .do_update() + .set(( + external_ids_dsl::external_id + .eq(diesel::upsert::excluded(external_ids_dsl::external_id)), + external_ids_dsl::updated_at + .eq(diesel::upsert::excluded(external_ids_dsl::updated_at)), + )) + .execute(conn)?; + } + + Ok(()) +} + +fn new_metadata_person_payload( + provider_id: String, + locale_key: String, + person: &ProviderMetadataPerson, +) -> NewMetadataPerson { + NewMetadataPerson { + provider_id, + external_id: normalized_external_id(person.external_id.as_deref()), + locale_key, + name: person.name.clone(), + known_for_json: known_for_json(&person.known_for), + biography: person.biography.clone(), + gender: person.gender.clone(), + birthday: person.birthday.clone(), + deathday: person.deathday.clone(), + birth_place: person.birth_place.clone(), + profile_url: person.profile_url.clone(), + image_url: person.image_url.clone(), + cached_image_path: person.cached_image_path.clone(), + updated_at: Some(current_timestamp()), + } +} + +fn merged_metadata_person_payload( + existing: &MetadataPerson, + person: &ProviderMetadataPerson, +) -> NewMetadataPerson { + NewMetadataPerson { + provider_id: existing.provider_id.clone(), + external_id: person + .external_id + .as_deref() + .and_then(|external_id| normalized_external_id(Some(external_id))) + .or_else(|| existing.external_id.clone()), + locale_key: existing.locale_key.clone(), + name: if person.name.trim().is_empty() { + existing.name.clone() + } else { + person.name.clone() + }, + known_for_json: known_for_json(&person.known_for) + .or_else(|| existing.known_for_json.clone()), + biography: person + .biography + .clone() + .or_else(|| existing.biography.clone()), + gender: person.gender.clone().or_else(|| existing.gender.clone()), + birthday: person + .birthday + .clone() + .or_else(|| existing.birthday.clone()), + deathday: person + .deathday + .clone() + .or_else(|| existing.deathday.clone()), + birth_place: person + .birth_place + .clone() + .or_else(|| existing.birth_place.clone()), + profile_url: person + .profile_url + .clone() + .or_else(|| existing.profile_url.clone()), + image_url: person + .image_url + .clone() + .or_else(|| existing.image_url.clone()), + cached_image_path: person + .cached_image_path + .clone() + .or_else(|| existing.cached_image_path.clone()), + updated_at: Some(current_timestamp()), + } +} + +fn known_for_json(values: &[String]) -> Option { + (!values.is_empty()) + .then(|| serde_json::to_string(values).ok()) + .flatten() +} + +fn find_metadata_person_for_provider_person( + conn: &mut SqliteConnection, + provider_id: &str, + locale_key: &str, + person: &ProviderMetadataPerson, +) -> Result, diesel::result::Error> { + use crate::db::schema::metadata_people::dsl as people_dsl; + + if let Some(external_id) = normalized_external_id(person.external_id.as_deref()) { + return people_dsl::metadata_people + .filter(people_dsl::provider_id.eq(provider_id)) + .filter(people_dsl::external_id.eq(external_id)) + .filter(people_dsl::locale_key.eq(locale_key)) + .select(MetadataPerson::as_select()) + .first(conn) + .optional(); + } + + let identity_key = provider_metadata_person_identity_key(person); + let rows = people_dsl::metadata_people + .filter(people_dsl::provider_id.eq(provider_id)) + .filter(people_dsl::external_id.is_null()) + .filter(people_dsl::locale_key.eq(locale_key)) + .select(MetadataPerson::as_select()) + .load::(conn)?; + Ok(rows + .into_iter() + .find(|row| metadata_person_identity_key(row) == identity_key)) +} + +fn metadata_person_identity_key(person: &MetadataPerson) -> String { + normalized_external_id(person.external_id.as_deref()) + .unwrap_or_else(|| format!("name:{}", normalized_person_name_key(&person.name))) +} + +fn provider_metadata_person_identity_key(person: &ProviderMetadataPerson) -> String { + normalized_external_id(person.external_id.as_deref()) + .unwrap_or_else(|| format!("name:{}", normalized_person_name_key(&person.name))) +} + +fn normalized_external_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|external_id| !external_id.is_empty()) + .map(ToOwned::to_owned) +} + +fn normalized_person_name_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn metadata_provider_id_from_db(value: &str) -> MetadataProviderId { + MetadataProviderId::from_storage_value(value).unwrap_or_else(|| { + log::warn!("Ignoring unexpected stored metadata provider id: {}", value); + MetadataProviderId::Tmdb + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleanup_movie_title_strips_tags_and_noise() { + assert_eq!( + cleanup_movie_title("Blade.Runner.1982.{edition-Final Cut}.1080p.BluRay.x264"), + "Blade Runner" + ); + assert_eq!( + parse_movie_name( + "Blade Runner (1982) {tmdb-78}/Blade Runner (1982) {edition-Final Cut}.mkv", + "Blade Runner (1982) {edition-Final Cut}" + ), + ParsedMovieName { + title: "Blade Runner".into(), + year: Some(1982), + provider_ids: HashMap::from([("tmdb".into(), "78".into())]), + } + ); + assert_eq!( + parse_movie_name( + "Beyond The Sky (2018) - Bluray-1080p [tmdb-332718:tvdb-12345].mkv", + "Beyond The Sky (2018) - Bluray-1080p" + ), + ParsedMovieName { + title: "Beyond The Sky".into(), + year: Some(2018), + provider_ids: HashMap::from([ + ("tmdb".into(), "332718".into()), + ("tvdb".into(), "12345".into()), + ]), + } + ); + assert_eq!( + parse_movie_name("2067 (2020) - 1080p.mkv", "2067 (2020) - 1080p"), + ParsedMovieName { + title: "2067".into(), + year: Some(2020), + provider_ids: HashMap::new(), + } + ); + assert_eq!( + parse_movie_name("2067/2067 (2020) - 1080p.mkv", "2067"), + ParsedMovieName { + title: "2067".into(), + year: Some(2020), + provider_ids: HashMap::new(), + } + ); + } + + #[test] + fn presentation_uses_only_stored_database_fields() { + let link = ItemMetadataLink { + id: 1, + media_item_id: 1, + provider_id: "unknown".into(), + external_id: "123".into(), + title: Some("Example".into()), + overview: Some("Stored overview wins.".into()), + tagline: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + media_type: Some("movie".into()), + relation_kind: "primary".into(), + match_state: "linked".into(), + logo_url: None, + cached_logo_path: None, + genres_json: Some(serde_json::json!(["Drama", "Mystery"]).to_string()), + rating: None, + content_rating: None, + locale_key: "en-US".into(), + provider_locale_key: None, + cached_artwork_path: None, + cached_backdrop_path: None, + refresh_state: "fresh".into(), + refresh_interval_seconds: 0, + last_refreshed_at: None, + next_refresh_at: None, + refresh_error: None, + updated_at: None, + }; + + let presentation = presentation_from_metadata_link(&link); + assert_eq!( + presentation.overview.as_deref(), + Some("Stored overview wins.") + ); + assert_eq!(presentation.genres, vec!["Drama", "Mystery"]); + } + + #[test] + fn person_search_matches_name_only_from_person_row() { + let person = MetadataPerson { + id: 1, + provider_id: "tmdb".into(), + external_id: Some("tom-cruise-external-id".into()), + locale_key: "en-US".into(), + name: "Tom Cruise".into(), + known_for_json: Some(serde_json::json!(["Mission: Impossible"]).to_string()), + biography: Some("Worked with many performers.".into()), + gender: None, + birthday: None, + deathday: None, + birth_place: None, + profile_url: None, + image_url: None, + cached_image_path: None, + updated_at: None, + }; + + assert!(metadata_person_matches_query(&person, "tom cruise")); + assert!(!metadata_person_matches_query(&person, "mission")); + assert!(!metadata_person_matches_query(&person, "performers")); + assert!(!metadata_person_matches_query(&person, "external")); + } + + #[test] + fn youtube_helpers_accept_common_video_url_shapes() { + assert_eq!( + extract_youtube_video_id("https://www.youtube.com/watch?v=SLBACEP6LsI&t=4s").as_deref(), + Some("SLBACEP6LsI") + ); + assert_eq!( + extract_youtube_video_id("https://youtu.be/SLBACEP6LsI").as_deref(), + Some("SLBACEP6LsI") + ); + assert_eq!( + extract_youtube_video_id("www.youtube.com/watch?v=SLBACEP6LsI").as_deref(), + Some("SLBACEP6LsI") + ); + assert_eq!( + extract_youtube_video_id("https://www.youtube.com/embed/SLBACEP6LsI?rel=0").as_deref(), + Some("SLBACEP6LsI") + ); + assert_eq!( + youtube_watch_url("https://www.youtube.com/shorts/SLBACEP6LsI").as_deref(), + Some("https://www.youtube.com/watch?v=SLBACEP6LsI") + ); + assert_eq!( + youtube_embed_url("SLBACEP6LsI", true).as_deref(), + Some("https://www.youtube.com/embed/SLBACEP6LsI?autoplay=1&rel=0") + ); + assert_eq!( + extract_youtube_video_id("https://example.com/watch?v=SLBACEP6LsI"), + None + ); + } + + #[test] + fn extra_type_normalization_covers_trailerdb_video_types_and_theme_song() { + let aliases = [ + ("Trailer", METADATA_EXTRA_TYPE_TRAILER), + ("teaser", METADATA_EXTRA_TYPE_TEASER), + ("Clip", METADATA_EXTRA_TYPE_CLIP), + ("scene", METADATA_EXTRA_TYPE_CLIP), + ("Behind The Scenes", METADATA_EXTRA_TYPE_BEHIND_THE_SCENES), + ("bloopers", METADATA_EXTRA_TYPE_BLOOPERS), + ("Featurette", METADATA_EXTRA_TYPE_FEATURETTE), + ("Opening Credits", METADATA_EXTRA_TYPE_OPENING_CREDITS), + ("recaps", METADATA_EXTRA_TYPE_RECAP), + ("Theme Song", METADATA_EXTRA_TYPE_THEME_SONG), + ]; + for (input, expected) in aliases { + assert_eq!( + normalize_metadata_extra_type(input).as_deref(), + Some(expected) + ); + } + for expected in [ + METADATA_EXTRA_TYPE_TRAILER, + METADATA_EXTRA_TYPE_TEASER, + METADATA_EXTRA_TYPE_CLIP, + METADATA_EXTRA_TYPE_BEHIND_THE_SCENES, + METADATA_EXTRA_TYPE_BLOOPERS, + METADATA_EXTRA_TYPE_FEATURETTE, + METADATA_EXTRA_TYPE_OPENING_CREDITS, + METADATA_EXTRA_TYPE_RECAP, + METADATA_EXTRA_TYPE_THEME_SONG, + ] { + assert!(SUPPORTED_METADATA_EXTRA_TYPES.contains(&expected)); + } + } + + #[test] + fn movie_match_score_prefers_matching_year() { + let parsed = ParsedMovieName { + title: "The Matrix".into(), + year: Some(1999), + provider_ids: HashMap::new(), + }; + let matching_year = MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: "movie".into(), + title: "The Matrix".into(), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + score: None, + }; + let wrong_year = MetadataSearchResult { + release_year: Some(2003), + ..matching_year.clone() + }; + + assert!( + movie_match_score(&parsed, &matching_year) > movie_match_score(&parsed, &wrong_year) + ); + } +} diff --git a/crates/server/src/metadata/providers/mod.rs b/crates/server/src/metadata/providers/mod.rs new file mode 100644 index 00000000..bf1c1110 --- /dev/null +++ b/crates/server/src/metadata/providers/mod.rs @@ -0,0 +1,710 @@ +pub(crate) mod themerr; +pub(crate) mod tmdb; +pub(crate) mod trailerdb; +pub(crate) mod tvdb; + +use std::future::Future; +use std::pin::Pin; + +use crate::config::{ + MediaLibraryKind, + MetadataProviderId, + MetadataSettings, +}; +use crate::metadata::{ + MetadataItemKind, + MetadataProviderDescriptor, + MetadataProviderRole, + MetadataSearchResult, + ProviderDescendantTarget, + ProviderMetadataCollection, + ProviderMetadataDetails, + ProviderMetadataPerson, + StoredMetadataSnapshot, + normalize_locale_key, +}; + +/// Boxed async result returned by metadata provider operations. +pub type MetadataProviderFuture<'a, T> = + Pin> + Send + 'a>>; + +/// Provider contract for metadata implementations. +pub trait MetadataProvider { + /// Return the provider descriptor. + fn descriptor(&self) -> MetadataProviderDescriptor; + + /// Map a provider-specific media type to Koko's provider-neutral item kind. + fn metadata_item_kind( + &self, + _media_type: Option<&str>, + ) -> MetadataItemKind { + MetadataItemKind::Item + } + + /// Map a Koko locale key to the provider's locale format. + fn provider_locale_key( + &self, + locale_key: &str, + ) -> String { + normalize_locale_key(locale_key) + } + + /// Whether this provider returns locale-specific metadata that should be stored per locale. + fn uses_localized_metadata(&self) -> bool { + false + } + + /// Search this provider for metadata candidates. + fn search<'a>( + &'a self, + _settings: &'a MetadataSettings, + _query: &'a str, + _media_type: Option<&'a str>, + ) -> MetadataProviderFuture<'a, Vec> { + unsupported_provider_operation(self.descriptor().display_name, "search") + } + + /// Fetch one provider metadata snapshot. + fn fetch_snapshot<'a>( + &'a self, + _settings: &'a MetadataSettings, + _external_id: &'a str, + _media_type: &'a str, + _include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + unsupported_provider_operation(self.descriptor().display_name, "metadata fetch") + } + + /// Fetch one season snapshot for a linked show descendant. + fn fetch_season_snapshot<'a>( + &'a self, + _settings: &'a MetadataSettings, + _show_external_id: &'a str, + _season_number: i32, + _season_external_id: Option<&'a str>, + _include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + unsupported_provider_operation(self.descriptor().display_name, "season metadata fetch") + } + + /// Fetch one episode snapshot for a linked show descendant. + fn fetch_episode_snapshot<'a>( + &'a self, + _settings: &'a MetadataSettings, + _show_external_id: &'a str, + _season_number: i32, + _episode_number: i32, + _episode_external_id: Option<&'a str>, + _include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + unsupported_provider_operation(self.descriptor().display_name, "episode metadata fetch") + } + + /// Fetch provider-side metadata for one person, when the provider supports it. + fn fetch_person_metadata<'a>( + &'a self, + _settings: &'a MetadataSettings, + _external_id: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async { Ok(None) }) + } + + /// Guess the best provider movie match for one library item. + fn guess_movie_match<'a>( + &'a self, + _settings: &'a MetadataSettings, + _relative_path: &'a str, + _display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async { Ok(None) }) + } + + /// Guess the best provider show match for one show item. + fn guess_show_match<'a>( + &'a self, + _settings: &'a MetadataSettings, + _relative_path: &'a str, + _display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async { Ok(None) }) + } + + /// Load provider-side descendant ids for a linked show. + fn load_show_descendant_targets<'a>( + &'a self, + _settings: &'a MetadataSettings, + _show_external_id: &'a str, + ) -> MetadataProviderFuture<'a, Vec> { + unsupported_provider_operation(self.descriptor().display_name, "show descendant lookup") + } + + /// Extract database-ready metadata fields from a provider snapshot. + fn metadata_details( + &self, + _snapshot: &StoredMetadataSnapshot, + ) -> ProviderMetadataDetails { + ProviderMetadataDetails::default() + } + + /// Cache provider-specific person artwork references into the snapshot payload. + fn cache_person_assets<'a>( + &'a self, + snapshot: &'a StoredMetadataSnapshot, + _data_dir: &'a str, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(async move { Ok(snapshot.clone()) }) + } + + /// Return whether this secondary provider should attempt a source metadata lookup candidate. + fn supports_secondary_metadata_reference( + &self, + source_provider_id: &MetadataProviderId, + item_type: &str, + database_id: &str, + ) -> bool { + self.secondary_metadata_reference_priority(source_provider_id, item_type, database_id) + .is_some() + } + + /// Sort priority for supported source metadata lookup candidates. + fn secondary_metadata_reference_priority( + &self, + _source_provider_id: &MetadataProviderId, + _item_type: &str, + _database_id: &str, + ) -> Option { + Some(0) + } + + /// Resolve item-level metadata fields contributed by a secondary provider. + fn fetch_secondary_metadata<'a>( + &'a self, + _item_type: &'a str, + _database_id: &'a str, + _external_id: &'a str, + _locale_key: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async { Ok(None) }) + } + + /// Resolve collection-level metadata fields contributed by a secondary provider. + fn fetch_secondary_collection_metadata<'a>( + &'a self, + _item_type: &'a str, + _database_id: &'a str, + _external_id: &'a str, + _locale_key: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async { Ok(None) }) + } +} + +fn unsupported_provider_operation<'a, T>( + display_name: String, + operation: &'static str, +) -> MetadataProviderFuture<'a, T> { + Box::pin(async move { Err(format!("{display_name} {operation} is not implemented.")) }) +} + +struct TmdbMetadataProvider; +struct TvdbMetadataProvider; +struct ThemerrMetadataProvider; +struct TrailerDbMetadataProvider; +struct MusicBrainzMetadataProvider; +struct OpenLibraryMetadataProvider; +struct LocalNfoMetadataProvider; + +impl MetadataProvider for TmdbMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + tmdb::descriptor() + } + + fn uses_localized_metadata(&self) -> bool { + true + } + + fn metadata_item_kind( + &self, + media_type: Option<&str>, + ) -> MetadataItemKind { + tmdb::metadata_item_kind(media_type) + } + + fn search<'a>( + &'a self, + settings: &'a MetadataSettings, + query: &'a str, + media_type: Option<&'a str>, + ) -> MetadataProviderFuture<'a, Vec> { + Box::pin(tmdb::search(settings, query, media_type)) + } + + fn fetch_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + external_id: &'a str, + media_type: &'a str, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tmdb::fetch_snapshot( + settings, + external_id, + media_type, + include_person_details, + )) + } + + fn fetch_season_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + season_number: i32, + _season_external_id: Option<&'a str>, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tmdb::fetch_season_snapshot( + settings, + show_external_id, + season_number, + include_person_details, + )) + } + + fn fetch_episode_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + season_number: i32, + episode_number: i32, + _episode_external_id: Option<&'a str>, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tmdb::fetch_episode_snapshot( + settings, + show_external_id, + season_number, + episode_number, + include_person_details, + )) + } + + fn fetch_person_metadata<'a>( + &'a self, + settings: &'a MetadataSettings, + external_id: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tmdb::fetch_person_metadata(settings, external_id)) + } + + fn guess_movie_match<'a>( + &'a self, + settings: &'a MetadataSettings, + relative_path: &'a str, + display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tmdb::guess_movie_match( + settings, + relative_path, + display_title, + )) + } + + fn guess_show_match<'a>( + &'a self, + settings: &'a MetadataSettings, + relative_path: &'a str, + display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tmdb::guess_show_match( + settings, + relative_path, + display_title, + )) + } + + fn load_show_descendant_targets<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + ) -> MetadataProviderFuture<'a, Vec> { + Box::pin(tmdb::load_show_descendant_targets( + settings, + show_external_id, + )) + } + + fn metadata_details( + &self, + snapshot: &StoredMetadataSnapshot, + ) -> ProviderMetadataDetails { + tmdb::metadata_details(snapshot) + } + + fn cache_person_assets<'a>( + &'a self, + snapshot: &'a StoredMetadataSnapshot, + data_dir: &'a str, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tmdb::cache_person_assets(snapshot, data_dir)) + } +} + +impl MetadataProvider for TvdbMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + tvdb::descriptor() + } + + fn uses_localized_metadata(&self) -> bool { + true + } + + fn metadata_item_kind( + &self, + media_type: Option<&str>, + ) -> MetadataItemKind { + tvdb::metadata_item_kind(media_type) + } + + fn provider_locale_key( + &self, + locale_key: &str, + ) -> String { + match normalize_locale_key(locale_key).as_str() { + "en-GB" | "en-US" => "eng", + "es" | "es-ES" => "spa", + "fr" | "fr-FR" => "fra", + "de" | "de-DE" => "deu", + "it" | "it-IT" => "ita", + "ja" | "ja-JP" => "jpn", + "pt" | "pt-BR" => "por", + _ => "eng", + } + .to_string() + } + + fn search<'a>( + &'a self, + settings: &'a MetadataSettings, + query: &'a str, + media_type: Option<&'a str>, + ) -> MetadataProviderFuture<'a, Vec> { + Box::pin(tvdb::search(settings, query, media_type)) + } + + fn fetch_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + external_id: &'a str, + media_type: &'a str, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tvdb::fetch_snapshot( + settings, + external_id, + media_type, + include_person_details, + )) + } + + fn fetch_season_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + season_number: i32, + season_external_id: Option<&'a str>, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(async move { + let season_external_id = season_external_id.ok_or_else(|| { + "TheTVDB season refresh is missing a season external id.".to_string() + })?; + tvdb::fetch_season_snapshot( + settings, + show_external_id, + season_number, + season_external_id, + include_person_details, + ) + .await + }) + } + + fn fetch_episode_snapshot<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + season_number: i32, + episode_number: i32, + episode_external_id: Option<&'a str>, + include_person_details: bool, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(async move { + let episode_external_id = episode_external_id.ok_or_else(|| { + "TheTVDB episode refresh is missing an episode external id.".to_string() + })?; + tvdb::fetch_episode_snapshot( + settings, + show_external_id, + season_number, + episode_number, + episode_external_id, + include_person_details, + ) + .await + }) + } + + fn fetch_person_metadata<'a>( + &'a self, + settings: &'a MetadataSettings, + external_id: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tvdb::fetch_person_metadata(settings, external_id)) + } + + fn guess_movie_match<'a>( + &'a self, + settings: &'a MetadataSettings, + relative_path: &'a str, + display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tvdb::guess_movie_match( + settings, + relative_path, + display_title, + )) + } + + fn guess_show_match<'a>( + &'a self, + settings: &'a MetadataSettings, + relative_path: &'a str, + display_title: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(tvdb::guess_show_match( + settings, + relative_path, + display_title, + )) + } + + fn load_show_descendant_targets<'a>( + &'a self, + settings: &'a MetadataSettings, + show_external_id: &'a str, + ) -> MetadataProviderFuture<'a, Vec> { + Box::pin(tvdb::load_show_descendant_targets( + settings, + show_external_id, + )) + } + + fn metadata_details( + &self, + snapshot: &StoredMetadataSnapshot, + ) -> ProviderMetadataDetails { + tvdb::metadata_details(snapshot) + } + + fn cache_person_assets<'a>( + &'a self, + snapshot: &'a StoredMetadataSnapshot, + data_dir: &'a str, + ) -> MetadataProviderFuture<'a, StoredMetadataSnapshot> { + Box::pin(tvdb::cache_person_assets(snapshot, data_dir)) + } +} + +impl MetadataProvider for ThemerrMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + themerr::descriptor() + } + + fn secondary_metadata_reference_priority( + &self, + source_provider_id: &MetadataProviderId, + item_type: &str, + database_id: &str, + ) -> Option { + themerr::item_lookup_reference_priority(source_provider_id, item_type, database_id) + } + + fn fetch_secondary_metadata<'a>( + &'a self, + item_type: &'a str, + database_id: &'a str, + external_id: &'a str, + _locale_key: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(themerr::fetch_youtube_theme_metadata( + item_type, + database_id, + external_id, + )) + } + + fn fetch_secondary_collection_metadata<'a>( + &'a self, + item_type: &'a str, + database_id: &'a str, + external_id: &'a str, + _locale_key: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(async move { + Ok( + themerr::fetch_youtube_theme_url(item_type, database_id, external_id) + .await? + .map(|theme_song_url| ProviderMetadataCollection { + external_id: format!("{item_type}:{database_id}:{external_id}"), + name: None, + overview: None, + artwork_url: None, + backdrop_url: None, + theme_song_url: Some(theme_song_url), + }), + ) + }) + } +} + +impl MetadataProvider for TrailerDbMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + trailerdb::descriptor() + } + + fn uses_localized_metadata(&self) -> bool { + true + } + + fn provider_locale_key( + &self, + locale_key: &str, + ) -> String { + trailerdb::provider_locale_key(locale_key) + } + + fn fetch_secondary_metadata<'a>( + &'a self, + item_type: &'a str, + database_id: &'a str, + external_id: &'a str, + locale_key: &'a str, + ) -> MetadataProviderFuture<'a, Option> { + Box::pin(trailerdb::fetch_secondary_metadata( + item_type, + database_id, + external_id, + locale_key, + )) + } +} + +impl MetadataProvider for MusicBrainzMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::MusicBrainz, + display_name: "MusicBrainz".into(), + description: "Planned music metadata provider for albums, artists, and tracks.".into(), + supported_kinds: vec![MediaLibraryKind::Music], + requires_api_key: false, + implemented: false, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "MusicBrainz metadata is provided by MusicBrainz.".into(), + attribution_url: "https://musicbrainz.org/".into(), + logo_light_url: None, + logo_dark_url: None, + } + } +} + +impl MetadataProvider for OpenLibraryMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::OpenLibrary, + display_name: "Open Library".into(), + description: "Planned book metadata provider for ebooks, PDFs, and comics.".into(), + supported_kinds: vec![MediaLibraryKind::Books], + requires_api_key: false, + implemented: false, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "Book metadata is provided by Open Library.".into(), + attribution_url: "https://openlibrary.org/".into(), + logo_light_url: None, + logo_dark_url: None, + } + } +} + +impl MetadataProvider for LocalNfoMetadataProvider { + fn descriptor(&self) -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::LocalNfo, + display_name: "Local NFO".into(), + description: "Planned sidecar metadata provider for locally curated libraries.".into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + MediaLibraryKind::Music, + MediaLibraryKind::Books, + MediaLibraryKind::HomeVideos, + ], + requires_api_key: false, + implemented: false, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "Local metadata is provided by files in your library.".into(), + attribution_url: String::new(), + logo_light_url: None, + logo_dark_url: None, + } + } +} + +/// Registry of known metadata providers. +pub struct MetadataRegistry { + providers: Vec>, +} + +impl MetadataRegistry { + /// Create a new registry containing the built-in providers. + pub fn new() -> Self { + Self { + providers: vec![ + Box::new(TmdbMetadataProvider), + Box::new(TvdbMetadataProvider), + Box::new(ThemerrMetadataProvider), + Box::new(TrailerDbMetadataProvider), + Box::new(MusicBrainzMetadataProvider), + Box::new(OpenLibraryMetadataProvider), + Box::new(LocalNfoMetadataProvider), + ], + } + } + + /// Return a provider by stable id. + pub fn provider( + &self, + provider_id: &MetadataProviderId, + ) -> Option<&(dyn MetadataProvider + Send + Sync)> { + self.providers + .iter() + .map(Box::as_ref) + .find(|provider| provider.descriptor().id == *provider_id) + } + + /// Return all built-in provider descriptors. + pub fn descriptors(&self) -> Vec { + self.providers + .iter() + .map(|provider| provider.descriptor()) + .collect() + } +} + +impl Default for MetadataRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/server/src/metadata/providers/themerr.rs b/crates/server/src/metadata/providers/themerr.rs new file mode 100644 index 00000000..43524809 --- /dev/null +++ b/crates/server/src/metadata/providers/themerr.rs @@ -0,0 +1,309 @@ +use serde_json::Value; + +use crate::config::MetadataProviderId; +use crate::metadata::{ + METADATA_EXTRA_TYPE_THEME_SONG, + MediaLibraryKind, + MetadataProviderDescriptor, + MetadataProviderRole, + ProviderMetadataDetails, + ProviderMetadataExtra, + youtube_watch_url, +}; + +const THEMERR_API_BASE: &str = "https://app.lizardbyte.dev/ThemerrDB"; + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::Themerr, + display_name: "ThemerrDB".into(), + description: "Secondary provider for theme-song metadata linked to movie, show, and \ + collection metadata." + .into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + ], + requires_api_key: false, + implemented: true, + role: MetadataProviderRole::Secondary, + extends_provider_ids: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::Tvdb, + ], + attribution_text: "Theme metadata provided by ThemerrDB.".into(), + attribution_url: "https://app.lizardbyte.dev/ThemerrDB".into(), + logo_light_url: Some( + "https://app.lizardbyte.dev/ThemerrDB/assets/img/navbar-avatar.png".into(), + ), + logo_dark_url: Some( + "https://app.lizardbyte.dev/ThemerrDB/assets/img/navbar-avatar.png".into(), + ), + } +} + +pub(crate) async fn fetch_youtube_theme_url( + item_type: &str, + database_id: &str, + external_id: &str, +) -> Result, String> { + let Some(database_path) = database_path_for_item_type(item_type) else { + return Ok(None); + }; + let Some(database_id) = normalize_database_id(item_type, database_id) else { + return Ok(None); + }; + let normalized_external_id = external_id.trim(); + if normalized_external_id.is_empty() { + return Ok(None); + } + + let response = reqwest::Client::new() + .get(format!( + "{}/{}/{}/{}.json", + THEMERR_API_BASE, database_path, database_id, normalized_external_id + )) + .send() + .await + .map_err(|error| error.to_string())?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "ThemerrDB lookup failed with status {}", + response.status() + )); + } + + let payload = response.text().await.map_err(|error| error.to_string())?; + Ok(parse_youtube_theme_url(&payload)) +} + +pub(crate) async fn fetch_youtube_theme_metadata( + item_type: &str, + database_id: &str, + external_id: &str, +) -> Result, String> { + let Some(theme_song_url) = fetch_youtube_theme_url(item_type, database_id, external_id).await? + else { + return Ok(None); + }; + let oembed = fetch_youtube_oembed_metadata(&theme_song_url).await; + + Ok(Some(ProviderMetadataDetails { + theme_song_url: Some(theme_song_url.clone()), + extras: vec![ProviderMetadataExtra { + extra_type: METADATA_EXTRA_TYPE_THEME_SONG.to_string(), + title: oembed.as_ref().and_then(|metadata| metadata.title.clone()), + url: theme_song_url, + duration_seconds: None, + thumbnail_url: oembed.and_then(|metadata| metadata.thumbnail_url), + sort_order: 0, + }], + ..ProviderMetadataDetails::default() + })) +} + +pub(crate) fn item_lookup_reference_priority( + source_provider_id: &MetadataProviderId, + item_type: &str, + database_id: &str, +) -> Option { + let normalized_database_id = normalize_database_id(item_type, database_id)?; + match source_provider_id { + MetadataProviderId::Tmdb | MetadataProviderId::Tvdb => { + if !themerr_supports_item_type(item_type) { + return None; + } + match normalized_database_id { + "themoviedb" => Some(0), + "imdb" => Some(1), + _ => None, + } + } + _ => None, + } +} + +#[derive(Debug, Clone)] +struct YoutubeOEmbedMetadata { + title: Option, + thumbnail_url: Option, +} + +async fn fetch_youtube_oembed_metadata(url: &str) -> Option { + let response = reqwest::Client::new() + .get("https://www.youtube.com/oembed") + .query(&[ + ("format", "json"), + ("url", url), + ]) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let payload = response.text().await.ok()?; + let payload = serde_json::from_str::(&payload).ok()?; + Some(YoutubeOEmbedMetadata { + title: text_field(&payload, &["title"]), + thumbnail_url: text_field(&payload, &["thumbnail_url"]), + }) +} + +fn database_path_for_item_type(item_type: &str) -> Option<&'static str> { + match item_type.trim() { + "movie" => Some("movies"), + "show" => Some("tv_shows"), + "collection" => Some("movie_collections"), + _ => None, + } +} + +fn themerr_supports_item_type(item_type: &str) -> bool { + matches!( + item_type.trim().to_ascii_lowercase().as_str(), + "movie" | "show" + ) +} + +fn normalize_database_id( + item_type: &str, + database_id: &str, +) -> Option<&'static str> { + let normalized_item_type = item_type.trim().to_ascii_lowercase(); + match database_id.trim().to_ascii_lowercase().as_str() { + "tmdb" => Some("themoviedb"), + "imdb" if normalized_item_type == "movie" => Some("imdb"), + _ => None, + } +} + +fn parse_youtube_theme_url(payload_json: &str) -> Option { + serde_json::from_str::(payload_json) + .ok()? + .get("youtube_theme_url")? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(youtube_watch_url) +} + +fn text_field( + value: &Value, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + database_path_for_item_type, + item_lookup_reference_priority, + normalize_database_id, + parse_youtube_theme_url, + }; + use crate::config::MetadataProviderId; + + #[test] + fn parse_youtube_theme_url_extracts_watch_url() { + let payload = serde_json::json!({ + "id": 603, + "title": "The Matrix", + "youtube_theme_url": "https://www.youtube.com/watch?v=SLBACEP6LsI" + }) + .to_string(); + + assert_eq!( + parse_youtube_theme_url(&payload).as_deref(), + Some("https://www.youtube.com/watch?v=SLBACEP6LsI") + ); + } + + #[test] + fn parse_youtube_theme_url_rejects_missing_url() { + let payload = serde_json::json!({ + "id": 1399, + "name": "Game of Thrones" + }) + .to_string(); + + assert_eq!(parse_youtube_theme_url(&payload), None); + } + + #[test] + fn collection_theme_lookup_uses_movie_collection_database() { + assert_eq!( + database_path_for_item_type("collection"), + Some("movie_collections") + ); + assert_eq!( + normalize_database_id("collection", "tmdb"), + Some("themoviedb") + ); + assert_eq!(normalize_database_id("collection", "imdb"), None); + } + + #[test] + fn imdb_theme_lookup_is_movie_only() { + assert_eq!(database_path_for_item_type("movie"), Some("movies")); + assert_eq!(database_path_for_item_type("show"), Some("tv_shows")); + assert_eq!(database_path_for_item_type("series"), None); + assert_eq!(database_path_for_item_type("tv"), None); + assert_eq!( + database_path_for_item_type("collection"), + Some("movie_collections") + ); + assert_eq!(normalize_database_id("movie", "imdb"), Some("imdb")); + assert_eq!(normalize_database_id("show", "imdb"), None); + assert_eq!(normalize_database_id("collection", "imdb"), None); + } + + #[test] + fn item_lookup_reference_support_follows_source_provider_and_item_type() { + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "movie", "tmdb").is_some() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "show", "tmdb").is_some() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "movie", "imdb").is_some() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "imdb").is_some() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "show", "tmdb").is_some() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "show", "imdb").is_none() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "series", "tmdb").is_none() + ); + assert!(item_lookup_reference_priority(&MetadataProviderId::Tvdb, "tv", "tmdb").is_none()); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "thetvdb").is_none() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tmdb, "collection", "tmdb") + .is_none() + ); + assert!( + item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "tmdb") + < item_lookup_reference_priority(&MetadataProviderId::Tvdb, "movie", "imdb") + ); + } +} diff --git a/crates/server/src/metadata/providers/tmdb.rs b/crates/server/src/metadata/providers/tmdb.rs new file mode 100644 index 00000000..3c2ac407 --- /dev/null +++ b/crates/server/src/metadata/providers/tmdb.rs @@ -0,0 +1,2068 @@ +use std::collections::{ + HashMap, + HashSet, +}; +use std::sync::Mutex; + +use once_cell::sync::Lazy; +use serde_json::Value; +use strsim::normalized_levenshtein; +use tmdb_client::apis::client::APIClient as TmdbApiClient; +use tmdb_client::models::{ + EpisodeDetails, + MovieDetails, + MovieObject, + SeasonDetails, + TvDetails, +}; + +use crate::config::{ + MetadataProviderId, + MetadataProviderSettings, + MetadataSettings, +}; +use crate::metadata::{ + MediaLibraryKind, + MetadataItemKind, + MetadataProviderDescriptor, + MetadataProviderRole, + MetadataSearchResult, + ProviderDescendantTarget, + ProviderExternalId, + ProviderMetadataCollection, + ProviderMetadataDetails, + ProviderMetadataPerson, + StoredMetadataSnapshot, + cleanup_movie_title, + extract_release_year, + managed_metadata_asset_dir, + metadata_asset_db_path, + metadata_response_cache_key, + movie_match_score, + normalize_external_id_source, + parse_movie_name, + preferred_image_url_by_format, + provider_settings, + read_metadata_response_cache_text, + show_search_query, + try_cache_item_artwork, + write_metadata_response_cache_text, +}; + +const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; +const TMDB_LOGO_URL: &str = concat!( + "https://www.themoviedb.org/assets/2/v4/logos/v2/", + "blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6b", + "ffa71d7f5c4.svg", +); + +static TMDB_PERSON_DETAIL_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); +static TMDB_COLLECTION_DETAIL_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::Tmdb, + display_name: "TheMovieDB".into(), + description: "Primary movie and television metadata provider for Koko.".into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + ], + requires_api_key: true, + implemented: true, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "Metadata and artwork provided by The Movie Database (TMDB).".into(), + attribution_url: "https://www.themoviedb.org/".into(), + logo_light_url: Some(TMDB_LOGO_URL.into()), + logo_dark_url: Some(TMDB_LOGO_URL.into()), + } +} + +pub(crate) fn metadata_item_kind(media_type: Option<&str>) -> MetadataItemKind { + match media_type + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "movie" => MetadataItemKind::Movie, + "tv" => MetadataItemKind::Show, + "tv_season" => MetadataItemKind::Season, + "tv_episode" => MetadataItemKind::Episode, + "collection" => MetadataItemKind::Collection, + "person" => MetadataItemKind::Person, + "company" => MetadataItemKind::Company, + _ => MetadataItemKind::Item, + } +} + +pub(crate) async fn search( + settings: &MetadataSettings, + query: &str, + media_type: Option<&str>, +) -> Result, String> { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let query = query.to_string(); + let language = provider.language; + let expected_media_type = media_type.map(|value| value.trim().to_ascii_lowercase()); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let payload = client + .search_api() + .get_search_multi_paginated(&query, Some(&language), Some(1), Some(false), None) + .map_err(|error| format!("TMDB search query {:?} failed: {}", query, error))?; + Ok(payload + .results + .unwrap_or_default() + .into_iter() + .filter_map(search_result_from_value) + .filter(|result| { + expected_media_type + .as_deref() + .map(|expected| result.media_type == expected) + .unwrap_or(true) + }) + .collect()) + }) + .await +} + +pub(crate) async fn fetch_snapshot( + settings: &MetadataSettings, + external_id: &str, + media_type: &str, + include_person_details: bool, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let image_languages = tmdb_include_image_languages(&language); + let external_id_number = parse_external_id(external_id, media_type)?; + let external_id_string = external_id.to_string(); + let normalized_media_type = media_type.trim().to_ascii_lowercase(); + + run_tmdb_blocking(move || match normalized_media_type.as_str() { + "movie" => { + let client = TmdbApiClient::new_with_api_key(api_key.clone()); + let details = client + .movies_api() + .get_movie_details( + external_id_number, + Some(&language), + Some(&image_languages), + Some("videos,images,release_dates,external_ids,credits"), + ) + .map_err(|error| { + format!( + "TMDB details lookup for movie:{} failed: {}", + external_id_string, error + ) + })?; + let payload_json = enriched_tmdb_payload_json( + &client, + &details, + &language, + &image_languages, + include_person_details, + ); + let mut snapshot = movie_snapshot_from_details(&external_id_string, &details); + snapshot.provider_payload_json = payload_json; + Ok(snapshot) + } + "tv" => { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_api() + .get_tv_details( + external_id_number, + Some(&language), + Some(&image_languages), + Some("videos,images,content_ratings,external_ids,credits"), + ) + .map_err(|error| { + format!( + "TMDB details lookup for tv:{} failed: {}", + external_id_string, error + ) + })?; + let payload_json = enriched_tmdb_payload_json( + &client, + &details, + &language, + &image_languages, + include_person_details, + ); + let mut snapshot = tv_snapshot_from_details(&external_id_string, &details); + snapshot.provider_payload_json = payload_json; + Ok(snapshot) + } + other => Err(format!("Unsupported TMDB media type: {}", other)), + }) + .await +} + +fn tmdb_include_image_languages(language: &str) -> String { + let base_language = language + .split(['-', '_']) + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("en"); + + if base_language.eq_ignore_ascii_case("null") { + "null".into() + } else { + format!("{base_language},null") + } +} + +pub(crate) async fn guess_movie_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let parsed = parse_movie_name(relative_path, display_title); + if parsed.title.trim().is_empty() { + return Ok(None); + } + + if let Some(tmdb_id) = parsed.provider_id("tmdb").map(str::to_string) { + let snapshot = fetch_snapshot(settings, &tmdb_id, "movie", false).await?; + return Ok(Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_id, + media_type: "movie".into(), + title: snapshot.title.unwrap_or(parsed.title), + overview: snapshot.overview, + artwork_url: snapshot.artwork_url, + backdrop_url: snapshot.backdrop_url, + release_year: snapshot.release_year, + score: Some(1.0), + })); + } + if let Some(tvdb_id) = parsed.provider_id("tvdb").map(str::to_string) { + if let Some(result) = find_tmdb_movie_by_external_id(settings, &tvdb_id, "tvdb_id").await? { + return Ok(Some(result)); + } + } + if let Some(imdb_id) = parsed.provider_id("imdb").map(str::to_string) { + if let Some(result) = find_tmdb_movie_by_external_id(settings, &imdb_id, "imdb_id").await? { + return Ok(Some(result)); + } + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search(settings, &parsed.title, Some("movie")).await? { + if result.media_type != "movie" { + continue; + } + + let score = movie_match_score(&parsed, &result); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +async fn find_tmdb_movie_by_external_id( + settings: &MetadataSettings, + external_id: &str, + external_source: &str, +) -> Result, String> { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let external_id = external_id.to_string(); + let external_source = external_source.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let payload = client + .find_api() + .get_find_external_id(&external_id, &external_source, Some(&language)) + .map_err(|error| { + format!( + "TMDB external id lookup for {}:{} failed: {}", + external_source, external_id, error + ) + })?; + Ok(payload + .movie_results + .unwrap_or_default() + .into_iter() + .find_map(movie_search_result_from_object)) + }) + .await +} + +pub(crate) async fn guess_show_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let query = show_search_query(relative_path, display_title); + if query.trim().is_empty() { + return Ok(None); + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search(settings, &query, Some("tv")).await? { + if result.media_type != "tv" { + continue; + } + + let score = normalized_levenshtein( + &cleanup_movie_title(&query).to_ascii_lowercase(), + &cleanup_movie_title(&result.title).to_ascii_lowercase(), + ); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +pub(crate) async fn load_show_descendant_targets( + settings: &MetadataSettings, + show_external_id: &str, +) -> Result, String> { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let show_id = parse_external_id(show_external_id, "tv")?; + let show_external_id = show_external_id.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_api() + .get_tv_details(show_id, Some(&language), None, None) + .map_err(|error| { + format!( + "TMDB descendant lookup for tv:{} failed: {}", + show_external_id, error + ) + })?; + + let mut season_numbers = details + .seasons + .unwrap_or_default() + .into_iter() + .filter_map(|season| season.season_number) + .filter(|season_number| *season_number > 0) + .collect::>(); + season_numbers.sort_unstable(); + season_numbers.dedup(); + + let mut targets = Vec::new(); + for season_number in season_numbers { + let season_details = client + .tv_seasons_api() + .get_tv_season_details(show_id, season_number, Some(&language), None, None) + .map_err(|error| { + format!( + "TMDB descendant lookup for tv:{} season {} failed: {}", + show_external_id, season_number, error + ) + })?; + for episode in season_details.episodes.unwrap_or_default() { + let Some(episode_number) = episode.episode_number.filter(|number| *number > 0) + else { + continue; + }; + targets.push(ProviderDescendantTarget { + season_number, + episode_number, + season_external_id: season_number.to_string(), + episode_external_id: episode_number.to_string(), + }); + } + } + + Ok(targets) + }) + .await +} + +pub(crate) async fn fetch_season_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + include_person_details: bool, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let show_id = parse_external_id(show_external_id, "tv")?; + let show_external_id = show_external_id.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_seasons_api() + .get_tv_season_details( + show_id, + season_number, + Some(&language), + None, + Some("credits"), + ) + .map_err(|error| { + format!( + "TMDB season lookup for tv:{}:season:{} failed: {}", + show_external_id, season_number, error + ) + })?; + let payload_json = enriched_tmdb_payload_json( + &client, + &details, + &language, + "null", + include_person_details, + ); + let mut snapshot = season_snapshot_from_details(&show_external_id, season_number, &details); + snapshot.provider_payload_json = payload_json; + Ok(snapshot) + }) + .await +} + +pub(crate) async fn fetch_episode_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + episode_number: i32, + include_person_details: bool, +) -> Result { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language; + let show_id = parse_external_id(show_external_id, "tv")?; + let show_external_id = show_external_id.to_string(); + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + let details = client + .tv_episodes_api() + .get_tv_season_episode_details( + show_id, + season_number, + episode_number, + Some(&language), + None, + Some("credits"), + ) + .map_err(|error| { + format!( + "TMDB episode lookup for tv:{}:season:{}:episode:{} failed: {}", + show_external_id, season_number, episode_number, error + ) + })?; + let payload_json = enriched_tmdb_payload_json( + &client, + &details, + &language, + "null", + include_person_details, + ); + let mut snapshot = episode_snapshot_from_details( + &show_external_id, + season_number, + episode_number, + &details, + ); + snapshot.provider_payload_json = payload_json; + Ok(snapshot) + }) + .await +} + +pub(crate) async fn fetch_person_metadata( + settings: &MetadataSettings, + external_id: &str, +) -> Result, String> { + let provider = tmdb_provider_settings(settings)?; + let api_key = tmdb_api_key_from_provider(&provider)?; + let language = provider.language.clone(); + let image_languages = tmdb_include_image_languages(&language); + let person_id = external_id.trim().parse::().map_err(|_| { + format!( + "TMDB person external id must be numeric, got {}", + external_id + ) + })?; + + run_tmdb_blocking(move || { + let client = TmdbApiClient::new_with_api_key(api_key); + Ok( + tmdb_cached_person_detail(&client, person_id, &language, &image_languages) + .and_then(|person| tmdb_person_from_detail(&person, person_id)), + ) + }) + .await +} + +fn parse_external_id( + external_id: &str, + media_type: &str, +) -> Result { + external_id.parse::().map_err(|_| { + format!( + "TMDB {} external id must be numeric, got {:?}", + media_type, external_id + ) + }) +} + +fn tmdb_provider_settings(settings: &MetadataSettings) -> Result { + provider_settings(settings, MetadataProviderId::Tmdb).map_err(|error| format!("TMDB {}", error)) +} + +fn tmdb_image_url( + path: &str, + size: &str, +) -> String { + format!( + "{}/{}/{}", + TMDB_IMAGE_BASE, + size, + path.trim_start_matches('/') + ) +} + +fn tmdb_season_external_id( + show_external_id: &str, + season_number: i32, +) -> String { + format!("tv:{show_external_id}:season:{season_number}") +} + +fn tmdb_episode_external_id( + show_external_id: &str, + season_number: i32, + episode_number: i32, +) -> String { + format!("tv:{show_external_id}:season:{season_number}:episode:{episode_number}") +} + +fn tmdb_api_key_from_provider( + provider: &crate::config::MetadataProviderSettings +) -> Result { + let api_key = provider.api_key.clone().unwrap_or_default(); + let api_key = api_key.trim(); + if api_key.is_empty() { + return Err("TMDB is enabled but no API key is configured.".into()); + } + + Ok(api_key.to_string()) +} + +async fn run_tmdb_blocking(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(|error| format!("TMDB request task failed: {}", error))? +} + +fn enriched_tmdb_payload_json( + client: &TmdbApiClient, + details: &T, + language: &str, + image_languages: &str, + include_person_details: bool, +) -> Option { + let mut payload = serde_json::to_value(details).ok()?; + enrich_tmdb_collection_payload(client, &mut payload, language); + if include_person_details { + enrich_tmdb_people_payload(client, &mut payload, language, image_languages); + } + serde_json::to_string(&payload).ok() +} + +fn enrich_tmdb_collection_payload( + client: &TmdbApiClient, + payload: &mut Value, + language: &str, +) { + let Some(collection_id) = payload + .get("belongs_to_collection") + .and_then(|collection| collection.get("id")) + .and_then(Value::as_i64) + .and_then(|id| i32::try_from(id).ok()) + else { + return; + }; + let Some(mut collection_details) = + tmdb_cached_collection_detail(client, collection_id, language) + else { + return; + }; + + if let (Some(original), Some(detailed)) = ( + payload + .get("belongs_to_collection") + .and_then(Value::as_object), + collection_details.as_object_mut(), + ) { + for (key, value) in original { + let detailed_value_missing = detailed.get(key).map(Value::is_null).unwrap_or(true); + if detailed_value_missing { + detailed.insert(key.clone(), value.clone()); + } + } + } + + if let Some(map) = payload.as_object_mut() { + map.insert("belongs_to_collection".into(), collection_details); + } +} + +fn enrich_tmdb_people_payload( + client: &TmdbApiClient, + payload: &mut Value, + language: &str, + image_languages: &str, +) { + let mut person_ids = Vec::new(); + if let Some(cast) = payload + .get("credits") + .and_then(|credits| credits.get("cast")) + .and_then(Value::as_array) + { + person_ids.extend( + cast.iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_i64)), + ); + } + for guest_stars in tmdb_guest_star_collections(payload) { + person_ids.extend( + guest_stars + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_i64)), + ); + } + if let Some(crew) = payload + .get("credits") + .and_then(|credits| credits.get("crew")) + .and_then(Value::as_array) + { + person_ids.extend(crew.iter().filter_map(|entry| { + let job = entry.get("job").and_then(Value::as_str)?; + matches_important_tmdb_crew_role(job) + .then(|| entry.get("id").and_then(Value::as_i64)) + .flatten() + })); + } + + let mut seen = HashSet::new(); + let people = person_ids + .into_iter() + .filter(|id| seen.insert(*id)) + .take(40) + .filter_map(|id| { + let person_id = i32::try_from(id).ok()?; + tmdb_cached_person_detail(client, person_id, language, image_languages) + .map(|details| (id, details)) + }) + .collect::>(); + + if people.is_empty() { + return; + } + + if let Some(credits) = payload.get_mut("credits") { + for collection_key in ["cast", "crew", "guest_stars"] { + if let Some(entries) = credits + .get_mut(collection_key) + .and_then(Value::as_array_mut) + { + for entry in entries { + let Some(id) = entry.get("id").and_then(Value::as_i64) else { + continue; + }; + let Some((_, person)) = people.iter().find(|(person_id, _)| *person_id == id) + else { + continue; + }; + if let Some(map) = entry.as_object_mut() { + map.insert("koko_person".into(), person.clone()); + } + } + } + } + } + if let Some(entries) = payload.get_mut("guest_stars").and_then(Value::as_array_mut) { + for entry in entries { + let Some(id) = entry.get("id").and_then(Value::as_i64) else { + continue; + }; + let Some((_, person)) = people.iter().find(|(person_id, _)| *person_id == id) else { + continue; + }; + if let Some(map) = entry.as_object_mut() { + map.insert("koko_person".into(), person.clone()); + } + } + } +} + +fn tmdb_cached_person_detail( + client: &TmdbApiClient, + person_id: i32, + language: &str, + image_languages: &str, +) -> Option { + let cache_key = metadata_response_cache_key( + &MetadataProviderId::Tmdb, + "person", + &[ + &person_id.to_string(), + language, + image_languages, + ], + ); + if let Some(cached) = TMDB_PERSON_DETAIL_CACHE + .lock() + .ok() + .and_then(|cache| cache.get(&cache_key).cloned()) + { + return Some(cached); + } + if let Some(contents) = read_metadata_response_cache_text(&cache_key) { + if let Ok(value) = serde_json::from_str::(&contents) { + if let Ok(mut cache) = TMDB_PERSON_DETAIL_CACHE.lock() { + cache.insert(cache_key.clone(), value.clone()); + } + return Some(value); + } + } + + let details = client + .people_api() + .get_person_details( + person_id, + Some(language), + Some(image_languages), + Some("combined_credits,external_ids,images"), + ) + .ok()?; + let mut value = serde_json::to_value(details).ok()?; + let known_for = tmdb_known_for_from_person_payload(&value); + if let Some(map) = value.as_object_mut() { + map.insert( + "koko_known_for".into(), + Value::Array(known_for.into_iter().map(Value::String).collect()), + ); + } + + if let Ok(mut cache) = TMDB_PERSON_DETAIL_CACHE.lock() { + if cache.len() > 5000 { + cache.clear(); + } + cache.insert(cache_key.clone(), value.clone()); + } + write_metadata_response_cache_text(&cache_key, &value.to_string()); + Some(value) +} + +fn tmdb_cached_collection_detail( + client: &TmdbApiClient, + collection_id: i32, + language: &str, +) -> Option { + let cache_key = metadata_response_cache_key( + &MetadataProviderId::Tmdb, + "collection", + &[ + &collection_id.to_string(), + language, + ], + ); + if let Some(cached) = TMDB_COLLECTION_DETAIL_CACHE + .lock() + .ok() + .and_then(|cache| cache.get(&cache_key).cloned()) + { + return Some(cached); + } + if let Some(contents) = read_metadata_response_cache_text(&cache_key) { + if let Ok(value) = serde_json::from_str::(&contents) { + if let Ok(mut cache) = TMDB_COLLECTION_DETAIL_CACHE.lock() { + cache.insert(cache_key.clone(), value.clone()); + } + return Some(value); + } + } + + let details = client + .collections_api() + .get_collection_details(collection_id, Some(language)) + .ok()?; + let value = serde_json::to_value(details).ok()?; + if let Ok(mut cache) = TMDB_COLLECTION_DETAIL_CACHE.lock() { + if cache.len() > 5000 { + cache.clear(); + } + cache.insert(cache_key.clone(), value.clone()); + } + write_metadata_response_cache_text(&cache_key, &value.to_string()); + Some(value) +} + +fn tmdb_known_for_from_person_payload(payload: &Value) -> Vec { + let Some(combined_credits) = payload.get("combined_credits") else { + return Vec::new(); + }; + let mut titles = Vec::new(); + for key in ["cast", "crew"] { + if let Some(entries) = combined_credits.get(key).and_then(Value::as_array) { + for entry in entries { + if let Some(title) = entry + .get("title") + .or_else(|| entry.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + { + if !titles.iter().any(|existing| existing == title) { + titles.push(title.to_string()); + } + } + if titles.len() >= 8 { + return titles; + } + } + } + } + titles +} + +fn matches_important_tmdb_crew_role(role: &str) -> bool { + matches!( + role, + "Director" + | "Writer" + | "Screenplay" + | "Story" + | "Creator" + | "Executive Producer" + | "Producer" + | "Original Music Composer" + | "Composer" + | "Director of Photography" + ) +} + +fn search_result_from_value(item: Value) -> Option { + let media_type = item.get("media_type")?.as_str()?.to_ascii_lowercase(); + if media_type != "movie" && media_type != "tv" { + return None; + } + + let external_id = item.get("id")?.as_i64()?.to_string(); + let title = item + .get("title") + .or_else(|| item.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)?; + let overview = item + .get("overview") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let artwork_url = item + .get("poster_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w500")); + let backdrop_url = item + .get("backdrop_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w1280")); + let release_year = item + .get("release_date") + .or_else(|| item.get("first_air_date")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))); + + Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id, + media_type, + title, + overview, + artwork_url, + backdrop_url, + release_year, + score: None, + }) +} + +fn movie_search_result_from_object(item: MovieObject) -> Option { + let external_id = item.id?.to_string(); + let title = item + .title + .as_deref() + .or(item.original_title.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)?; + Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tmdb, + external_id, + media_type: "movie".into(), + title, + overview: item + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: item + .poster_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: item + .backdrop_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w1280")), + release_year: item + .release_date + .and_then(|value| extract_release_year(Some(value))), + score: None, + }) +} + +fn movie_snapshot_from_details( + external_id: &str, + details: &MovieDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: external_id.to_string(), + media_type: Some("movie".into()), + title: details.title.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .poster_path + .as_deref() + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: details + .backdrop_path + .as_deref() + .map(|path| tmdb_image_url(path, "w1280")), + release_year: details + .release_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +fn tv_snapshot_from_details( + external_id: &str, + details: &TvDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: external_id.to_string(), + media_type: Some("tv".into()), + title: details.name.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .poster_path + .as_deref() + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: details + .backdrop_path + .as_deref() + .map(|path| tmdb_image_url(path, "w1280")), + release_year: details + .first_air_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +fn season_snapshot_from_details( + show_external_id: &str, + season_number: i32, + details: &SeasonDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_season_external_id(show_external_id, season_number), + media_type: Some("tv_season".into()), + title: details.name.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .poster_path + .as_deref() + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: None, + release_year: details + .air_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +fn episode_snapshot_from_details( + show_external_id: &str, + season_number: i32, + episode_number: i32, + details: &EpisodeDetails, +) -> StoredMetadataSnapshot { + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: tmdb_episode_external_id(show_external_id, season_number, episode_number), + media_type: Some("tv_episode".into()), + title: details.name.clone(), + overview: details + .overview + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + artwork_url: details + .still_path + .as_deref() + .map(|path| tmdb_image_url(path, "w780")), + backdrop_url: None, + release_year: details + .air_date + .clone() + .and_then(|value| extract_release_year(Some(value))), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: serde_json::to_string(details).ok(), + } +} + +pub(crate) fn metadata_details(snapshot: &StoredMetadataSnapshot) -> ProviderMetadataDetails { + let Some(payload) = snapshot + .provider_payload_json + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + else { + return ProviderMetadataDetails::default(); + }; + + let trailer = tmdb_trailer_entry(&payload); + ProviderMetadataDetails { + external_ids: tmdb_external_ids(&payload, snapshot), + tagline: text_field(&payload, &["tagline"]), + logo_url: tmdb_logo_url(&payload), + genres: tmdb_genres(&payload), + rating: payload + .get("vote_average") + .and_then(Value::as_f64) + .map(|value| value as f32), + content_rating: tmdb_content_rating(&payload), + trailer_title: trailer + .and_then(|entry| entry.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + trailer_url: trailer + .and_then(|entry| { + entry + .get("site") + .and_then(Value::as_str) + .zip(entry.get("key").and_then(Value::as_str)) + }) + .and_then(|(site, key)| youtube_watch_url(site, key)), + collections: tmdb_collections(&payload), + people: tmdb_people(&payload), + ..ProviderMetadataDetails::default() + } +} + +fn tmdb_external_ids( + payload: &Value, + snapshot: &StoredMetadataSnapshot, +) -> Vec { + let mut external_ids = Vec::new(); + push_external_id(&mut external_ids, "tmdb", Some(&snapshot.external_id)); + extend_tmdb_external_ids_from_value(&mut external_ids, payload); + if let Some(ids) = payload.get("external_ids") { + extend_tmdb_external_ids_from_value(&mut external_ids, ids); + } + external_ids +} + +fn extend_tmdb_external_ids_from_value( + external_ids: &mut Vec, + value: &Value, +) { + let Some(map) = value.as_object() else { + return; + }; + for (key, value) in map { + let Some(source) = tmdb_external_id_source_from_key(key) else { + continue; + }; + push_external_id_value(external_ids, &source, value); + } +} + +fn tmdb_external_id_source_from_key(key: &str) -> Option { + let key = key.trim(); + if key.ends_with("_mid") { + return normalize_external_id_source(key); + } + normalize_external_id_source(key.strip_suffix("_id")?) +} + +fn push_external_id_value( + external_ids: &mut Vec, + source: &str, + value: &Value, +) { + let external_id = value + .as_str() + .map(str::to_string) + .or_else(|| value.as_i64().map(|id| id.to_string())) + .or_else(|| value.as_u64().map(|id| id.to_string())); + push_external_id(external_ids, source, external_id.as_deref()); +} + +fn push_external_id( + external_ids: &mut Vec, + source: &str, + external_id: Option<&str>, +) { + let Some(external_id) = external_id.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + let Some(source) = normalize_external_id_source(source) else { + return; + }; + if external_ids + .iter() + .any(|existing| existing.source == source && existing.external_id == external_id) + { + return; + } + external_ids.push(ProviderExternalId { + source, + external_id: external_id.to_string(), + }); +} + +pub(crate) async fn cache_person_assets( + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result { + let Some(payload_json) = snapshot.provider_payload_json.as_deref() else { + return Ok(snapshot.clone()); + }; + let mut payload = + serde_json::from_str::(payload_json).map_err(|error| error.to_string())?; + cache_tmdb_people_payload_images(&mut payload, snapshot, data_dir).await?; + + let mut next_snapshot = snapshot.clone(); + next_snapshot.provider_payload_json = Some(payload.to_string()); + Ok(next_snapshot) +} + +async fn cache_tmdb_people_payload_images( + payload: &mut Value, + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result<(), String> { + if let Some(credits) = payload.get_mut("credits") { + for collection_key in ["cast", "crew", "guest_stars"] { + let Some(entries) = credits + .get_mut(collection_key) + .and_then(Value::as_array_mut) + else { + continue; + }; + cache_tmdb_people_entries_images(entries, snapshot, data_dir).await; + } + } + if let Some(entries) = payload.get_mut("guest_stars").and_then(Value::as_array_mut) { + cache_tmdb_people_entries_images(entries, snapshot, data_dir).await; + } + Ok(()) +} + +async fn cache_tmdb_people_entries_images( + entries: &mut [Value], + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) { + for entry in entries { + let Some(external_id) = person_external_id(entry) else { + continue; + }; + let image_url = entry + .get("koko_person") + .and_then(|person| person.get("profile_path")) + .or_else(|| entry.get("profile_path")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(|path| { + if path.starts_with("http://") || path.starts_with("https://") { + path.to_string() + } else { + tmdb_image_url(path, "w185") + } + }); + let Some(image_url) = image_url else { + continue; + }; + let person_dir = managed_metadata_asset_dir( + data_dir, + snapshot.provider_id.clone(), + &external_id, + Some("person"), + &snapshot.locale_key, + ); + let cache_key = format!("{}_profile", snapshot.provider_id.as_storage_value()); + let Some(path) = try_cache_item_artwork(&image_url, &person_dir, &cache_key).await else { + continue; + }; + let cached_path = metadata_asset_db_path(data_dir, &path); + if let Some(map) = entry.as_object_mut() { + map.insert( + "koko_cached_image_path".into(), + Value::String(cached_path.clone()), + ); + if let Some(person) = map.get_mut("koko_person").and_then(Value::as_object_mut) { + person.insert("koko_cached_image_path".into(), Value::String(cached_path)); + } + } + } +} + +fn tmdb_trailer_entry(payload: &Value) -> Option<&Value> { + payload + .get("videos") + .and_then(|videos| videos.get("results")) + .and_then(Value::as_array) + .and_then(|videos| { + videos + .iter() + .find(|video| { + video.get("site").and_then(Value::as_str) == Some("YouTube") + && video.get("type").and_then(Value::as_str) == Some("Trailer") + && video + .get("official") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .or_else(|| { + videos.iter().find(|video| { + video.get("site").and_then(Value::as_str) == Some("YouTube") + && video.get("type").and_then(Value::as_str) == Some("Trailer") + }) + }) + .or_else(|| { + videos + .iter() + .find(|video| video.get("site").and_then(Value::as_str) == Some("YouTube")) + }) + }) +} + +fn youtube_watch_url( + site: &str, + key: &str, +) -> Option { + site.eq_ignore_ascii_case("YouTube") + .then(|| key.trim()) + .filter(|key| !key.is_empty()) + .and_then(crate::metadata::youtube_watch_url) +} + +fn tmdb_logo_url(payload: &Value) -> Option { + payload + .get("images") + .and_then(|images| images.get("logos")) + .and_then(Value::as_array) + .and_then(|logos| { + preferred_image_url_by_format(logos.iter().filter_map(|logo| { + logo.get("file_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(|path| tmdb_image_url(path, "w500")) + })) + }) +} + +fn tmdb_genres(payload: &Value) -> Vec { + payload + .get("genres") + .and_then(Value::as_array) + .map(|genres| { + genres + .iter() + .filter_map(|genre| genre.get("name").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default() +} + +fn tmdb_content_rating(payload: &Value) -> Option { + payload + .get("release_dates") + .and_then(|release_dates| release_dates.get("results")) + .and_then(Value::as_array) + .and_then(|countries| { + countries + .iter() + .find(|country| country.get("iso_3166_1").and_then(Value::as_str) == Some("US")) + .or_else(|| countries.first()) + }) + .and_then(|country| country.get("release_dates")) + .and_then(Value::as_array) + .and_then(|dates| { + dates.iter().find_map(|date| { + date.get("certification") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + }) + .or_else(|| { + payload + .get("content_ratings") + .and_then(|ratings| ratings.get("results")) + .and_then(Value::as_array) + .and_then(|ratings| { + ratings + .iter() + .find(|rating| { + rating.get("iso_3166_1").and_then(Value::as_str) == Some("US") + }) + .or_else(|| ratings.first()) + }) + .and_then(|rating| rating.get("rating")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn tmdb_collections(payload: &Value) -> Vec { + let Some(collection) = payload.get("belongs_to_collection") else { + return Vec::new(); + }; + let Some(external_id) = collection.get("id").and_then(Value::as_i64) else { + return Vec::new(); + }; + let Some(name) = collection + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return Vec::new(); + }; + + vec![ProviderMetadataCollection { + external_id: external_id.to_string(), + name: Some(name.to_string()), + overview: text_field(collection, &["overview"]), + artwork_url: collection + .get("poster_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w500")), + backdrop_url: collection + .get("backdrop_path") + .and_then(Value::as_str) + .map(|path| tmdb_image_url(path, "w1280")), + theme_song_url: None, + }] +} + +fn tmdb_guest_star_collections(payload: &Value) -> Vec<&Vec> { + let mut collections = Vec::new(); + if let Some(guest_stars) = payload.get("guest_stars").and_then(Value::as_array) { + collections.push(guest_stars); + } + if let Some(guest_stars) = payload + .get("credits") + .and_then(|credits| credits.get("guest_stars")) + .and_then(Value::as_array) + { + collections.push(guest_stars); + } + collections +} + +fn tmdb_people(payload: &Value) -> Vec { + let Some(credits) = payload.get("credits") else { + let mut people = Vec::new(); + extend_tmdb_guest_stars(&mut people, payload); + return sort_and_dedupe_people(people); + }; + + let mut people = Vec::new(); + if let Some(cast) = credits.get("cast").and_then(Value::as_array) { + people.extend(cast.iter().enumerate().filter_map(|(index, entry)| { + let name = person_name(entry)?; + Some(ProviderMetadataPerson { + external_id: person_external_id(entry), + external_ids: tmdb_person_external_ids(entry, None), + name, + known_for: tmdb_person_known_for(entry), + biography: tmdb_person_detail(entry, "biography"), + gender: tmdb_person_gender(entry), + birthday: tmdb_person_detail(entry, "birthday"), + deathday: tmdb_person_detail(entry, "deathday"), + birth_place: tmdb_person_detail(entry, "place_of_birth"), + role: Some("Actor".into()), + department: Some("Cast".into()), + character_name: text_field(entry, &["character"]), + profile_url: person_external_id(entry) + .map(|id| format!("https://www.themoviedb.org/person/{id}")), + image_url: entry + .get("profile_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w185")), + cached_image_path: text_field(entry, &["koko_cached_image_path"]), + sort_order: entry + .get("order") + .and_then(Value::as_i64) + .and_then(|order| i32::try_from(order).ok()) + .unwrap_or_else(|| i32::try_from(index).unwrap_or(i32::MAX)), + }) + })); + } + + extend_tmdb_guest_stars(&mut people, payload); + + if let Some(crew) = credits.get("crew").and_then(Value::as_array) { + let mut crew_order = 10_000; + people.extend(crew.iter().filter_map(|entry| { + let job = text_field(entry, &["job"])?; + if !matches_important_tmdb_crew_role(&job) { + return None; + } + let name = person_name(entry)?; + let sort_order = crew_order; + crew_order += 1; + Some(ProviderMetadataPerson { + external_id: person_external_id(entry), + external_ids: tmdb_person_external_ids(entry, None), + name, + known_for: tmdb_person_known_for(entry), + biography: tmdb_person_detail(entry, "biography"), + gender: tmdb_person_gender(entry), + birthday: tmdb_person_detail(entry, "birthday"), + deathday: tmdb_person_detail(entry, "deathday"), + birth_place: tmdb_person_detail(entry, "place_of_birth"), + role: Some(job), + department: text_field(entry, &["department"]).or_else(|| Some("Crew".into())), + character_name: None, + profile_url: person_external_id(entry) + .map(|id| format!("https://www.themoviedb.org/person/{id}")), + image_url: entry + .get("profile_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w185")), + cached_image_path: text_field(entry, &["koko_cached_image_path"]), + sort_order, + }) + })); + } + + sort_and_dedupe_people(people) +} + +fn extend_tmdb_guest_stars( + people: &mut Vec, + payload: &Value, +) { + let mut guest_order = 5_000; + for guest_stars in tmdb_guest_star_collections(payload) { + people.extend(guest_stars.iter().filter_map(|entry| { + let name = person_name(entry)?; + let sort_order = entry + .get("order") + .and_then(Value::as_i64) + .and_then(|order| i32::try_from(order).ok()) + .map(|order| 5_000 + order) + .unwrap_or_else(|| { + let sort_order = guest_order; + guest_order += 1; + sort_order + }); + Some(ProviderMetadataPerson { + external_id: person_external_id(entry), + external_ids: tmdb_person_external_ids(entry, None), + name, + known_for: tmdb_person_known_for(entry), + biography: tmdb_person_detail(entry, "biography"), + gender: tmdb_person_gender(entry), + birthday: tmdb_person_detail(entry, "birthday"), + deathday: tmdb_person_detail(entry, "deathday"), + birth_place: tmdb_person_detail(entry, "place_of_birth"), + role: Some("Guest Star".into()), + department: Some("Cast".into()), + character_name: text_field(entry, &["character"]), + profile_url: person_external_id(entry) + .map(|id| format!("https://www.themoviedb.org/person/{id}")), + image_url: entry + .get("profile_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w185")), + cached_image_path: text_field(entry, &["koko_cached_image_path"]), + sort_order, + }) + })); + } +} + +fn tmdb_person_from_detail( + person: &Value, + fallback_id: i32, +) -> Option { + let name = person_name(person)?; + let fallback_id_string = fallback_id.to_string(); + Some(ProviderMetadataPerson { + external_id: person_external_id(person).or_else(|| Some(fallback_id_string.clone())), + external_ids: tmdb_person_external_ids(person, Some(&fallback_id_string)), + name, + known_for: person + .get("koko_known_for") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(), + biography: text_field(person, &["biography"]), + gender: tmdb_person_gender_from_value(person), + birthday: text_field(person, &["birthday"]), + deathday: text_field(person, &["deathday"]), + birth_place: text_field(person, &["place_of_birth"]), + role: None, + department: None, + character_name: None, + profile_url: person_external_id(person) + .or_else(|| Some(fallback_id.to_string())) + .map(|id| format!("https://www.themoviedb.org/person/{id}")), + image_url: person + .get("profile_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| tmdb_image_url(path, "w185")), + cached_image_path: text_field(person, &["koko_cached_image_path"]), + sort_order: 0, + }) +} + +fn tmdb_person_external_ids( + value: &Value, + fallback_external_id: Option<&str>, +) -> Vec { + let details = value.get("koko_person"); + let mut external_ids = Vec::new(); + let primary_external_id = person_external_id(value) + .or_else(|| details.and_then(person_external_id)) + .or_else(|| fallback_external_id.map(ToOwned::to_owned)); + push_external_id(&mut external_ids, "tmdb", primary_external_id.as_deref()); + + if fallback_external_id.is_some() { + extend_tmdb_external_ids_from_value(&mut external_ids, value); + } + if let Some(ids) = value.get("external_ids") { + extend_tmdb_external_ids_from_value(&mut external_ids, ids); + } + if let Some(details) = details { + extend_tmdb_external_ids_from_value(&mut external_ids, details); + if let Some(ids) = details.get("external_ids") { + extend_tmdb_external_ids_from_value(&mut external_ids, ids); + } + } + external_ids +} + +fn tmdb_person_detail( + credit: &Value, + key: &str, +) -> Option { + credit + .get("koko_person") + .and_then(|person| person.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn tmdb_person_gender(credit: &Value) -> Option { + let gender = credit + .get("koko_person") + .and_then(|person| person.get("gender")) + .and_then(Value::as_i64)?; + tmdb_gender_label(gender) +} + +fn tmdb_person_gender_from_value(person: &Value) -> Option { + let gender = person.get("gender").and_then(Value::as_i64)?; + tmdb_gender_label(gender) +} + +fn tmdb_gender_label(gender: i64) -> Option { + match gender { + 1 => Some("Female".into()), + 2 => Some("Male".into()), + 3 => Some("Non-binary".into()), + _ => None, + } +} + +fn tmdb_person_known_for(credit: &Value) -> Vec { + credit + .get("koko_person") + .and_then(|person| person.get("koko_known_for")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .take(8) + .collect() + }) + .unwrap_or_default() +} + +fn person_name(value: &Value) -> Option { + text_field( + value, + &[ + "name", + "original_name", + "fullName", + ], + ) +} + +fn person_external_id(value: &Value) -> Option { + value + .get("id") + .or_else(|| value.get("peopleId")) + .or_else(|| value.get("personId")) + .and_then(|id| { + id.as_i64() + .map(|id| id.to_string()) + .or_else(|| id.as_str().map(str::to_string)) + }) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) +} + +fn text_field( + value: &Value, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn sort_and_dedupe_people(people: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut people = people + .into_iter() + .filter(|person| { + let key = format!( + "{}:{}:{}", + person.external_id.as_deref().unwrap_or(""), + person.name.to_ascii_lowercase(), + person.role.as_deref().unwrap_or("") + ); + seen.insert(key) + }) + .collect::>(); + people.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.department.cmp(&right.department)) + .then_with(|| left.name.cmp(&right.name)) + }); + people.truncate(80); + people +} + +#[cfg(test)] +mod tests { + use super::{ + metadata_details, + metadata_item_kind, + tmdb_logo_url, + }; + use crate::config::MetadataProviderId; + use crate::metadata::{ + MetadataItemKind, + StoredMetadataSnapshot, + }; + use serde_json::json; + + #[test] + fn tmdb_logo_url_prefers_svg_over_png() { + let payload = json!({ + "images": { + "logos": [ + { "file_path": "/logo.png" }, + { "file_path": "/logo.svg" } + ] + } + }); + + assert_eq!( + tmdb_logo_url(&payload).as_deref(), + Some("https://image.tmdb.org/t/p/w500/logo.svg") + ); + } + + #[test] + fn tmdb_logo_url_falls_back_to_png_when_svg_missing() { + let payload = json!({ + "images": { + "logos": [ + { "file_path": "/logo.jpg" }, + { "file_path": "/logo.png" } + ] + } + }); + + assert_eq!( + tmdb_logo_url(&payload).as_deref(), + Some("https://image.tmdb.org/t/p/w500/logo.png") + ); + } + + #[test] + fn tmdb_metadata_item_kind_uses_exact_provider_media_types() { + assert_eq!(metadata_item_kind(Some("movie")), MetadataItemKind::Movie); + assert_eq!(metadata_item_kind(Some("tv")), MetadataItemKind::Show); + assert_eq!( + metadata_item_kind(Some("tv_season")), + MetadataItemKind::Season + ); + assert_eq!( + metadata_item_kind(Some("tv_episode")), + MetadataItemKind::Episode + ); + assert_eq!(metadata_item_kind(Some("person")), MetadataItemKind::Person); + assert_eq!(metadata_item_kind(Some("series")), MetadataItemKind::Item); + assert_eq!(metadata_item_kind(Some("people")), MetadataItemKind::Item); + } + + #[test] + fn tmdb_metadata_details_collects_known_external_ids() { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + json!({ + "imdb_id": "tt0133093", + "external_ids": { + "wikidata_id": "Q83495", + "facebook_id": "thematrixmovie", + "freebase_mid": "/m/0f2y0" + } + }) + .to_string(), + ), + }; + + let details = metadata_details(&snapshot); + + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "tmdb" && id.external_id == "603" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "imdb" && id.external_id == "tt0133093" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "wikidata" && id.external_id == "Q83495" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "facebook" && id.external_id == "thematrixmovie" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "freebase_mid" && id.external_id == "/m/0f2y0" }) + ); + } + + #[test] + fn tmdb_metadata_details_use_shallow_credit_people_without_person_detail_payload() { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + json!({ + "credits": { + "cast": [ + { + "cast_id": 7, + "credit_id": "52fe425bc3a36847f80181c7", + "id": 6384, + "name": "Keanu Reeves", + "character": "Neo", + "order": 0, + "profile_path": "/keanu.jpg" + } + ], + "crew": [] + } + }) + .to_string(), + ), + }; + + let details = metadata_details(&snapshot); + + assert_eq!(details.people.len(), 1); + assert_eq!(details.people[0].name, "Keanu Reeves"); + assert_eq!(details.people[0].biography, None); + assert_eq!(details.people[0].external_id.as_deref(), Some("6384")); + assert_eq!(details.people[0].character_name.as_deref(), Some("Neo")); + } + + #[test] + fn tmdb_people_include_external_ids_from_person_detail_payload() { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + json!({ + "credits": { + "cast": [ + { + "id": 6384, + "name": "Keanu Reeves", + "character": "Neo", + "order": 0, + "koko_person": { + "id": 6384, + "name": "Keanu Reeves", + "external_ids": { + "imdb_id": "nm0000206", + "wikidata_id": "Q43416", + "freebase_mid": "/m/01vvycq" + } + } + } + ], + "crew": [] + } + }) + .to_string(), + ), + }; + + let details = metadata_details(&snapshot); + + assert_eq!(details.people.len(), 1); + assert!( + details.people[0] + .external_ids + .iter() + .any(|id| { id.source == "tmdb" && id.external_id == "6384" }) + ); + assert!( + details.people[0] + .external_ids + .iter() + .any(|id| { id.source == "imdb" && id.external_id == "nm0000206" }) + ); + assert!( + details.people[0] + .external_ids + .iter() + .any(|id| { id.source == "wikidata" && id.external_id == "Q43416" }) + ); + assert!( + details.people[0] + .external_ids + .iter() + .any(|id| { id.source == "freebase_mid" && id.external_id == "/m/01vvycq" }) + ); + assert!( + !details.people[0] + .external_ids + .iter() + .any(|id| id.source == "cast" || id.source == "credit") + ); + } + + #[test] + fn tmdb_metadata_details_include_episode_guest_stars_as_shallow_people() { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "tv:1396:season:1:episode:1".into(), + media_type: Some("tv_episode".into()), + title: Some("Pilot".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2008), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + json!({ + "guest_stars": [ + { + "id": 1111, + "name": "Guest One", + "character": "Episode Character", + "order": 2, + "profile_path": "/guest-one.jpg" + } + ], + "credits": { + "cast": [], + "crew": [], + "guest_stars": [ + { + "id": 2222, + "name": "Guest Two", + "character": "Another Character", + "order": 1, + "profile_path": "/guest-two.jpg" + } + ] + } + }) + .to_string(), + ), + }; + + let details = metadata_details(&snapshot); + + assert_eq!(details.people.len(), 2); + assert_eq!(details.people[0].name, "Guest Two"); + assert_eq!(details.people[0].role.as_deref(), Some("Guest Star")); + assert_eq!(details.people[0].department.as_deref(), Some("Cast")); + assert_eq!( + details.people[0].character_name.as_deref(), + Some("Another Character") + ); + assert_eq!(details.people[0].biography, None); + assert_eq!( + details.people[0].image_url.as_deref(), + Some("https://image.tmdb.org/t/p/w185/guest-two.jpg") + ); + assert_eq!(details.people[1].name, "Guest One"); + assert_eq!(details.people[1].role.as_deref(), Some("Guest Star")); + } +} diff --git a/crates/server/src/metadata/providers/trailerdb.rs b/crates/server/src/metadata/providers/trailerdb.rs new file mode 100644 index 00000000..112e6dbd --- /dev/null +++ b/crates/server/src/metadata/providers/trailerdb.rs @@ -0,0 +1,356 @@ +use serde_json::Value; + +use crate::config::MetadataProviderId; +use crate::metadata::{ + METADATA_EXTRA_TYPE_TRAILER, + MediaLibraryKind, + MetadataProviderDescriptor, + MetadataProviderRole, + ProviderMetadataDetails, + ProviderMetadataExtra, + normalize_locale_key, + normalize_metadata_extra_type, + youtube_watch_url, +}; + +const TRAILERDB_DATA_BASE: &str = "https://trailerdb.org/data"; + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::TrailerDb, + display_name: "TrailerDB".into(), + description: "Secondary provider for localized movie and show trailer metadata.".into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + ], + requires_api_key: false, + implemented: true, + role: MetadataProviderRole::Secondary, + extends_provider_ids: vec![MetadataProviderId::Tmdb], + attribution_text: "Trailer metadata provided by The Trailer Database.".into(), + attribution_url: "https://trailerdb.org/".into(), + logo_light_url: None, + logo_dark_url: None, + } +} + +pub(crate) fn provider_locale_key(locale_key: &str) -> String { + normalize_locale_key(locale_key) + .split('-') + .next() + .unwrap_or("en") + .to_ascii_lowercase() +} + +pub(crate) async fn fetch_secondary_metadata( + item_type: &str, + database_id: &str, + external_id: &str, + locale_key: &str, +) -> Result, String> { + let Some(path) = trailerdb_path(item_type, database_id, external_id) else { + return Ok(None); + }; + + let response = reqwest::Client::new() + .get(format!("{TRAILERDB_DATA_BASE}/{path}.json")) + .send() + .await + .map_err(|error| error.to_string())?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "TrailerDB lookup failed with status {}", + response.status() + )); + } + + let payload = response.text().await.map_err(|error| error.to_string())?; + Ok(parse_youtube_trailer(&payload, locale_key)) +} + +fn trailerdb_path( + item_type: &str, + database_id: &str, + external_id: &str, +) -> Option { + let external_id = external_id.trim(); + if external_id.is_empty() { + return None; + } + + match ( + item_type.trim().to_ascii_lowercase().as_str(), + database_id.trim().to_ascii_lowercase().as_str(), + ) { + ("movie", "imdb") => Some(format!("movie/{external_id}")), + ("show", "tmdb") => Some(format!("series/{external_id}")), + _ => None, + } +} + +fn parse_youtube_trailer( + payload_json: &str, + locale_key: &str, +) -> Option { + let payload = serde_json::from_str::(payload_json).ok()?; + let language = provider_locale_key(locale_key); + + let mut extras = extras_from_groups(payload.get("trailer_groups"), &language); + if extras.is_empty() { + extras = extras_from_entries(payload.get("trailers"), &language); + } + if extras.is_empty() { + return None; + } + + let preferred_trailer = extras + .iter() + .find(|extra| extra.extra_type == METADATA_EXTRA_TYPE_TRAILER) + .or_else(|| extras.first()); + Some(ProviderMetadataDetails { + trailer_title: preferred_trailer.and_then(|extra| extra.title.clone()), + trailer_url: preferred_trailer.map(|extra| extra.url.clone()), + extras, + ..ProviderMetadataDetails::default() + }) +} + +fn extras_from_groups( + groups: Option<&Value>, + language: &str, +) -> Vec { + groups + .and_then(Value::as_array) + .map(|groups| { + groups + .iter() + .enumerate() + .filter_map(|(index, group)| { + let languages = group.get("languages")?.as_object()?; + let translation = languages + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(language)) + .map(|(_, value)| value)?; + let youtube_id = text_field(translation, &["youtube_id"])?; + let url = youtube_watch_url(&youtube_id)?; + let extra_type = normalize_metadata_extra_type(&text_field(group, &["type"])?) + .unwrap_or_else(|| METADATA_EXTRA_TYPE_TRAILER.to_string()); + let title = text_field(translation, &["title"]) + .or_else(|| text_field(group, &["title"])); + Some(ProviderMetadataExtra { + extra_type, + title, + url, + duration_seconds: None, + thumbnail_url: None, + sort_order: index as i32, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn extras_from_entries( + trailers: Option<&Value>, + language: &str, +) -> Vec { + let Some(trailers) = trailers.and_then(Value::as_array) else { + return Vec::new(); + }; + + let mut entries = trailers + .iter() + .enumerate() + .filter(|entry| { + text_field(entry.1, &["language"]) + .as_deref() + .is_some_and(|entry_language| entry_language.eq_ignore_ascii_case(language)) + }) + .collect::>(); + entries.sort_by(|(left_index, left), (right_index, right)| { + trailer_entry_score(right) + .cmp(&trailer_entry_score(left)) + .then_with(|| left_index.cmp(right_index)) + }); + + entries + .into_iter() + .enumerate() + .filter_map(|(sort_order, (_, entry))| extra_from_entry(entry, sort_order as i32)) + .collect() +} + +fn extra_from_entry( + entry: &Value, + sort_order: i32, +) -> Option { + let youtube_id = text_field(entry, &["youtube_id"])?; + let url = youtube_watch_url(&youtube_id)?; + let extra_type = text_field(entry, &["type", "trailer_type"]) + .and_then(|value| normalize_metadata_extra_type(&value)) + .unwrap_or_else(|| METADATA_EXTRA_TYPE_TRAILER.to_string()); + Some(ProviderMetadataExtra { + extra_type, + title: text_field(entry, &["title"]), + url, + duration_seconds: int_field(entry, &["duration", "duration_seconds"]), + thumbnail_url: text_field(entry, &["thumbnail_url", "thumbnail"]), + sort_order, + }) +} + +fn trailer_entry_score(entry: &Value) -> (i32, i32) { + let official_score = entry + .get("is_official") + .and_then(Value::as_bool) + .map(i32::from) + .unwrap_or(0); + let trailer_type_score = text_field(entry, &["type", "trailer_type"]) + .map(|value| value.eq_ignore_ascii_case("trailer") as i32) + .unwrap_or(0); + (official_score, trailer_type_score) +} + +fn text_field( + value: &Value, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn int_field( + value: &Value, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .filter(|value| *value > 0) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + parse_youtube_trailer, + trailerdb_path, + }; + + #[test] + fn movie_lookup_uses_imdb_detail_endpoint() { + assert_eq!( + trailerdb_path("movie", "imdb", "tt0133093").as_deref(), + Some("movie/tt0133093") + ); + assert_eq!(trailerdb_path("movie", "tmdb", "603"), None); + } + + #[test] + fn show_lookup_uses_tmdb_series_detail_endpoint() { + assert_eq!( + trailerdb_path("show", "tmdb", "1399").as_deref(), + Some("series/1399") + ); + assert_eq!(trailerdb_path("tv", "tmdb", "1399"), None); + assert_eq!(trailerdb_path("series", "tmdb", "1399"), None); + assert_eq!(trailerdb_path("show", "themoviedb", "1399"), None); + assert_eq!(trailerdb_path("show", "imdb", "tt0944947"), None); + } + + #[test] + fn trailer_groups_return_requested_language_only() { + let payload = serde_json::json!({ + "trailer_groups": [ + { + "group_id": "official", + "type": "Trailer", + "title": "Official Trailer", + "languages": { + "en": { + "youtube_id": "abcdefghijk", + "title": "Official Trailer" + }, + "es": { + "youtube_id": "ZYXWVUT9876", + "title": "Trailer oficial" + } + } + } + ] + }) + .to_string(); + + let trailer = parse_youtube_trailer(&payload, "es-ES").expect("Expected trailer"); + + assert_eq!(trailer.trailer_title.as_deref(), Some("Trailer oficial")); + assert_eq!( + trailer.trailer_url.as_deref(), + Some("https://www.youtube.com/watch?v=ZYXWVUT9876") + ); + assert_eq!(parse_youtube_trailer(&payload, "fr-FR"), None); + } + + #[test] + fn trailers_return_requested_official_trailer() { + let payload = serde_json::json!({ + "trailers": [ + { + "youtube_id": "aaaaaaaaaaa", + "title": "Spanish clip", + "type": "Clip", + "language": "es", + "is_official": false, + "duration": 52 + }, + { + "youtube_id": "bbbbbbbbbbb", + "title": "Trailer oficial", + "type": "Trailer", + "language": "es", + "is_official": true, + "duration": 148 + }, + { + "youtube_id": "ccccccccccc", + "title": "Official Trailer", + "type": "Trailer", + "language": "en", + "is_official": true + } + ] + }) + .to_string(); + + let trailer = parse_youtube_trailer(&payload, "es").expect("Expected trailer"); + + assert_eq!(trailer.trailer_title.as_deref(), Some("Trailer oficial")); + assert_eq!( + trailer.trailer_url.as_deref(), + Some("https://www.youtube.com/watch?v=bbbbbbbbbbb") + ); + assert_eq!( + trailer + .extras + .iter() + .map(|extra| extra.extra_type.as_str()) + .collect::>(), + vec!["trailer", "clip"] + ); + assert_eq!(trailer.extras[0].duration_seconds, Some(148)); + } +} diff --git a/crates/server/src/metadata/providers/tvdb.rs b/crates/server/src/metadata/providers/tvdb.rs new file mode 100644 index 00000000..59f355cb --- /dev/null +++ b/crates/server/src/metadata/providers/tvdb.rs @@ -0,0 +1,2766 @@ +use serde_json::Value; +use strsim::normalized_levenshtein; +use tvdb4::apis::{ + self, + login_api, +}; +use tvdb4::models::LoginPostRequest; + +use crate::config::{ + MetadataProviderId, + MetadataProviderSettings, + MetadataSettings, +}; +use crate::metadata::{ + MediaLibraryKind, + MetadataItemKind, + MetadataProviderDescriptor, + MetadataProviderRole, + MetadataSearchResult, + ProviderDescendantTarget, + ProviderExternalId, + ProviderMetadataDetails, + ProviderMetadataPerson, + StoredMetadataSnapshot, + cleanup_movie_title, + extract_release_year, + format_payload_snippet, + image_format_preference_rank, + managed_metadata_asset_dir, + metadata_asset_db_path, + metadata_response_cache_key, + movie_match_score, + normalize_external_id_source, + parse_movie_name, + provider_settings, + read_metadata_response_cache_text, + show_search_query, + try_cache_item_artwork, + write_metadata_response_cache_text, +}; +use std::time::{ + Duration, + Instant, +}; + +const TVDB_API_BASE: &str = "https://api4.thetvdb.com/v4"; + +static TVDB_RATE_LIMITER: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(Instant::now())); +static TVDB_AUTH_TOKEN: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(None)); + +#[derive(Debug, Clone)] +struct TvdbCachedToken { + token: String, + expires_at: Instant, +} + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::Tvdb, + display_name: "TheTVDB".into(), + description: "Alternative movie and television metadata provider with strong series and \ + episode coverage." + .into(), + supported_kinds: vec![ + MediaLibraryKind::Movies, + MediaLibraryKind::Shows, + ], + requires_api_key: true, + implemented: true, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "Metadata and artwork provided by TheTVDB.".into(), + attribution_url: "https://thetvdb.com/".into(), + logo_light_url: Some("https://thetvdb.com/images/attribution/logo2.png".into()), + logo_dark_url: Some("https://thetvdb.com/images/attribution/logo1.png".into()), + } +} + +pub(crate) fn metadata_item_kind(media_type: Option<&str>) -> MetadataItemKind { + match media_type + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "movie" => MetadataItemKind::Movie, + "series" => MetadataItemKind::Show, + "season" => MetadataItemKind::Season, + "episode" => MetadataItemKind::Episode, + "list" => MetadataItemKind::Collection, + "people" => MetadataItemKind::Person, + "company" => MetadataItemKind::Company, + "award" => MetadataItemKind::Award, + _ => MetadataItemKind::Item, + } +} + +pub(crate) async fn search( + settings: &MetadataSettings, + query: &str, + media_type: Option<&str>, +) -> Result, String> { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let mut query_params = vec![ + ("query", query.to_string()), + ("limit", "20".to_string()), + ]; + if let Some(media_type) = media_type.map(|value| value.trim().to_ascii_lowercase()) { + query_params.push(("type", media_type)); + } + let payload = get_json( + &provider, + "search", + query_params, + &format!("search query {:?}", query), + ) + .await?; + let results = payload + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let expected_media_type = media_type.map(|value| value.trim().to_ascii_lowercase()); + Ok(results + .into_iter() + .filter_map(|result| search_result_from_value(result, &provider.language)) + .filter(|result| { + expected_media_type + .as_deref() + .map(|expected| result.media_type == expected) + .unwrap_or(true) + }) + .collect()) +} + +pub(crate) async fn fetch_snapshot( + settings: &MetadataSettings, + external_id: &str, + media_type: &str, + include_person_details: bool, +) -> Result { + match media_type { + "movie" => { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let payload = + fetch_movie_payload(settings, external_id, include_person_details).await?; + let translation = + fetch_translation_payload(&provider, "movies", external_id, &provider.language) + .await; + Ok(movie_snapshot_from_value( + external_id, + &payload, + translation.as_ref(), + &provider.language, + )) + } + "series" => { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let payload = + fetch_series_payload(settings, external_id, include_person_details).await?; + let translation = + fetch_translation_payload(&provider, "series", external_id, &provider.language) + .await; + Ok(series_snapshot_from_value( + external_id, + &payload, + translation.as_ref(), + &provider.language, + )) + } + "season" => { + fetch_season_snapshot( + settings, + external_id, + 0, + external_id, + include_person_details, + ) + .await + } + "episode" => { + fetch_episode_snapshot( + settings, + external_id, + 0, + 0, + external_id, + include_person_details, + ) + .await + } + other => Err(format!("Unsupported TheTVDB media type: {}", other)), + } +} + +pub(crate) async fn guess_movie_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let parsed = parse_movie_name(relative_path, display_title); + if parsed.title.trim().is_empty() { + return Ok(None); + } + + if let Some(tvdb_id) = parsed.provider_id("tvdb").map(str::to_string) { + let snapshot = fetch_snapshot(settings, &tvdb_id, "movie", false).await?; + return Ok(Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tvdb, + external_id: tvdb_id, + media_type: "movie".into(), + title: snapshot.title.unwrap_or(parsed.title), + overview: snapshot.overview, + artwork_url: snapshot.artwork_url, + backdrop_url: snapshot.backdrop_url, + release_year: snapshot.release_year, + score: Some(1.0), + })); + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search(settings, &parsed.title, Some("movie")).await? { + if result.media_type != "movie" { + continue; + } + + let score = movie_match_score(&parsed, &result); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +pub(crate) async fn guess_show_match( + settings: &MetadataSettings, + relative_path: &str, + display_title: &str, +) -> Result, String> { + let query = show_search_query(relative_path, display_title); + if query.trim().is_empty() { + return Ok(None); + } + + let mut best_result = None; + let mut best_score = 0.0; + for result in search(settings, &query, Some("series")).await? { + if result.media_type != "series" { + continue; + } + + let score = normalized_levenshtein( + &cleanup_movie_title(&query).to_ascii_lowercase(), + &cleanup_movie_title(&result.title).to_ascii_lowercase(), + ); + if score > best_score { + best_score = score; + best_result = Some(result); + } + } + + Ok((best_score >= 0.78).then_some(best_result).flatten()) +} + +pub(crate) async fn fetch_season_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + season_external_id: &str, + include_person_details: bool, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let season_id = parse_tvdb_external_id(season_external_id, "season")?; + let mut payload = get_json( + &provider, + &format!("seasons/{season_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("season lookup for series:{show_external_id}:season:{season_external_id}"), + ) + .await?; + if include_person_details { + enrich_tvdb_people_payload(&provider, &mut payload).await; + } + let translation = + fetch_translation_payload(&provider, "seasons", season_external_id, &provider.language) + .await; + Ok(season_snapshot_from_value( + show_external_id, + season_number, + season_external_id, + &payload, + translation.as_ref(), + &provider.language, + )) +} + +pub(crate) async fn fetch_episode_snapshot( + settings: &MetadataSettings, + show_external_id: &str, + season_number: i32, + episode_number: i32, + episode_external_id: &str, + include_person_details: bool, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let episode_id = parse_tvdb_external_id(episode_external_id, "episode")?; + let mut payload = get_json( + &provider, + &format!("episodes/{episode_id}/extended"), + vec![("meta", "translations".to_string())], + &format!( + "episode lookup for \ + series:{show_external_id}:season:{season_number}:episode:{episode_external_id}" + ), + ) + .await?; + if include_person_details { + enrich_tvdb_people_payload(&provider, &mut payload).await; + } + let translation = fetch_translation_payload( + &provider, + "episodes", + episode_external_id, + &provider.language, + ) + .await; + Ok(episode_snapshot_from_value( + show_external_id, + season_number, + episode_number, + episode_external_id, + &payload, + translation.as_ref(), + &provider.language, + )) +} + +pub(crate) async fn load_show_descendant_targets( + settings: &MetadataSettings, + show_external_id: &str, +) -> Result, String> { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let show_id = parse_tvdb_external_id(show_external_id, "series")?; + let series_payload = get_json( + &provider, + &format!("series/{show_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("series descendant lookup for {show_external_id}"), + ) + .await?; + + let mut targets = Vec::new(); + for season in series_payload + .get("data") + .and_then(|value| value.get("seasons")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + let Some(season_id) = object_id(&season) else { + continue; + }; + let Some(season_number) = season_number(&season) else { + continue; + }; + + let season_payload = get_json( + &provider, + &format!("seasons/{season_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("season descendant lookup for {season_id}"), + ) + .await?; + let episodes = season_payload + .get("data") + .and_then(|value| value.get("episodes")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for episode in episodes { + let Some(episode_id) = object_id(&episode) else { + continue; + }; + let Some(episode_number) = episode_number(&episode) else { + continue; + }; + targets.push(ProviderDescendantTarget { + season_number, + episode_number, + season_external_id: season_id.to_string(), + episode_external_id: episode_id.to_string(), + }); + } + } + + Ok(targets) +} + +fn tvdb_configuration(token: Option) -> apis::configuration::Configuration { + let mut config = apis::configuration::Configuration::new(); + config.base_path = TVDB_API_BASE.to_string(); + config.user_agent = Some(format!("Koko/{}", env!("CARGO_PKG_VERSION"))); + config.bearer_access_token = token; + config +} + +fn parse_tvdb_external_id( + external_id: &str, + media_type: &str, +) -> Result { + external_id.parse::().map_err(|_| { + format!( + "TheTVDB {} external id must be numeric, got {:?}", + media_type, external_id + ) + }) +} + +async fn wait_for_rate_limit(provider: &MetadataProviderSettings) { + let requests_per_second = provider.rate_limit_per_second.max(1); + let interval = Duration::from_secs_f64(1.0 / f64::from(requests_per_second)); + let mut next_available_at = TVDB_RATE_LIMITER.lock().await; + let now = Instant::now(); + if *next_available_at > now { + tokio::time::sleep((*next_available_at).saturating_duration_since(now)).await; + } + let base = Instant::now(); + *next_available_at = base.checked_add(interval).unwrap_or(base); +} + +async fn auth_token(provider: &MetadataProviderSettings) -> Result { + let now = Instant::now(); + { + let cache = TVDB_AUTH_TOKEN.lock().await; + if let Some(cached) = cache.as_ref().filter(|cached| cached.expires_at > now) { + return Ok(cached.token.clone()); + } + } + + let api_key = provider + .api_key + .clone() + .unwrap_or_default() + .trim() + .to_string(); + if api_key.is_empty() { + return Err("TheTVDB is enabled but no API key is configured.".into()); + } + + wait_for_rate_limit(provider).await; + let config = tvdb_configuration(None); + let response = login_api::login_post(&config, LoginPostRequest::new(api_key)) + .await + .map_err(|error| format_tvdb_error("login", error))?; + let token = response + .data + .and_then(|data| data.token) + .map(|token| token.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "TheTVDB login response did not include a token.".to_string())?; + + let cached = TvdbCachedToken { + token: token.clone(), + expires_at: Instant::now() + Duration::from_secs(60 * 60 * 24 * 25), + }; + let mut cache = TVDB_AUTH_TOKEN.lock().await; + *cache = Some(cached); + Ok(token) +} + +fn format_tvdb_error( + context: &str, + error: apis::Error, +) -> String { + match error { + apis::Error::ResponseError(response) => format!( + "TheTVDB {} failed with status {}{}", + context, + response.status, + format_payload_snippet(&response.content) + ), + other => format!("TheTVDB {} request failed: {}", context, other), + } +} + +async fn get_text( + provider: &MetadataProviderSettings, + path: &str, + query: Vec<(&'static str, String)>, + context: &str, +) -> Result { + let query_key = query + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&"); + let cache_key = + metadata_response_cache_key(&MetadataProviderId::Tvdb, "http", &[path, &query_key]); + if let Some(payload) = read_metadata_response_cache_text(&cache_key) { + return Ok(payload); + } + + let retry_attempts = usize::try_from(provider.retry_attempts).unwrap_or(0); + let base_backoff = Duration::from_millis(u64::from(provider.retry_backoff_ms.max(1))); + + for attempt in 0..=retry_attempts { + wait_for_rate_limit(provider).await; + let token = auth_token(provider).await?; + let config = tvdb_configuration(Some(token)); + let request_url = format!("{}/{}", TVDB_API_BASE, path.trim_start_matches('/')); + let mut request = config + .client + .get(&request_url) + .bearer_auth(config.bearer_access_token.as_deref().unwrap_or_default()) + .query(&query); + if let Some(user_agent) = config.user_agent.as_deref() { + request = request.header("user-agent", user_agent); + } + let response = request.send().await; + + match response { + Ok(response) => { + let status = response.status(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(parse_retry_after_seconds) + .map(Duration::from_secs); + let payload = response.text().await.map_err(|error| error.to_string())?; + if status.is_success() { + write_metadata_response_cache_text(&cache_key, &payload); + return Ok(payload); + } + + if status.as_u16() == 401 { + let mut cache = TVDB_AUTH_TOKEN.lock().await; + *cache = None; + } + + let rate_limited = status.as_u16() == 429 + || retry_after.is_some() + || payload.to_ascii_lowercase().contains("rate limit"); + let retryable = status.as_u16() == 401 || rate_limited || status.is_server_error(); + if retryable && attempt < retry_attempts { + let attempt_number = attempt + 1; + let multiplier = 1_u32 + .checked_shl(u32::try_from(attempt).unwrap_or(0)) + .unwrap_or(u32::MAX); + let backoff = + retry_after.unwrap_or_else(|| base_backoff.saturating_mul(multiplier)); + log::warn!( + "TheTVDB request retry scheduled for {} after status {}{}{} (attempt \ + {}/{} in {} ms)", + context, + status, + if rate_limited { " [rate limited]" } else { "" }, + format_payload_snippet(&payload), + attempt_number, + retry_attempts + 1, + backoff.as_millis() + ); + tokio::time::sleep(backoff).await; + continue; + } + + return Err(format!( + "TheTVDB {} failed with status {}{}{}", + context, + status, + if rate_limited { " [rate limited]" } else { "" }, + format_payload_snippet(&payload) + )); + } + Err(error) => { + if attempt < retry_attempts { + let attempt_number = attempt + 1; + let multiplier = 1_u32 + .checked_shl(u32::try_from(attempt).unwrap_or(0)) + .unwrap_or(u32::MAX); + let backoff = base_backoff.saturating_mul(multiplier); + log::warn!( + "TheTVDB request retry scheduled for {} after transport error: {} \ + (attempt {}/{} in {} ms)", + context, + error, + attempt_number, + retry_attempts + 1, + backoff.as_millis() + ); + tokio::time::sleep(backoff).await; + continue; + } + + return Err(format!("TheTVDB {} request failed: {}", context, error)); + } + } + } + + Err(format!("TheTVDB {} request failed after retries", context)) +} + +async fn get_json( + provider: &MetadataProviderSettings, + path: &str, + query: Vec<(&'static str, String)>, + context: &str, +) -> Result { + let payload = get_text(provider, path, query, context).await?; + serde_json::from_str::(&payload) + .map_err(|error| format!("TheTVDB {} returned invalid JSON: {}", context, error)) +} + +async fn enrich_tvdb_people_payload( + provider: &MetadataProviderSettings, + payload: &mut Value, +) { + let data = match payload { + Value::Object(map) if map.contains_key("data") => map.get_mut("data").unwrap(), + _ => payload, + }; + let entries = if data.get("characters").is_some() { + data.get_mut("characters").and_then(Value::as_array_mut) + } else { + data.get_mut("people").and_then(Value::as_array_mut) + }; + let Some(entries) = entries else { + return; + }; + + let mut seen = std::collections::HashSet::new(); + let person_ids = entries + .iter() + .filter_map(|entry| { + entry + .get("peopleId") + .or_else(|| entry.get("personId")) + .and_then(Value::as_i64) + }) + .filter(|id| seen.insert(*id)) + .take(80) + .collect::>(); + if person_ids.is_empty() { + return; + } + + let mut people = std::collections::HashMap::new(); + for person_id in person_ids { + let Ok(person) = get_json( + provider, + &format!("people/{person_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("people lookup for {person_id}"), + ) + .await + else { + continue; + }; + let person = person.get("data").cloned().unwrap_or(person); + people.insert(person_id, person); + } + if people.is_empty() { + return; + } + + for entry in entries { + let Some(person_id) = entry + .get("peopleId") + .or_else(|| entry.get("personId")) + .and_then(Value::as_i64) + else { + continue; + }; + let Some(person) = people.get(&person_id) else { + continue; + }; + if let Some(map) = entry.as_object_mut() { + map.insert("koko_person".into(), person.clone()); + } + } +} + +fn parse_retry_after_seconds(value: &str) -> Option { + value.trim().parse::().ok() +} + +async fn fetch_movie_payload( + settings: &MetadataSettings, + external_id: &str, + include_person_details: bool, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let movie_id = parse_tvdb_external_id(external_id, "movie")?; + let mut payload = get_json( + &provider, + &format!("movies/{movie_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("movie details lookup for {external_id}"), + ) + .await?; + if include_person_details { + enrich_tvdb_people_payload(&provider, &mut payload).await; + } + Ok(payload) +} + +async fn fetch_series_payload( + settings: &MetadataSettings, + external_id: &str, + include_person_details: bool, +) -> Result { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let series_id = parse_tvdb_external_id(external_id, "series")?; + let mut payload = get_json( + &provider, + &format!("series/{series_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("series details lookup for {external_id}"), + ) + .await?; + if include_person_details { + enrich_tvdb_people_payload(&provider, &mut payload).await; + } + Ok(payload) +} + +pub(crate) async fn fetch_person_metadata( + settings: &MetadataSettings, + external_id: &str, +) -> Result, String> { + let provider = provider_settings(settings, MetadataProviderId::Tvdb) + .map_err(|error| format!("TheTVDB {}", error))?; + let person_id = parse_tvdb_external_id(external_id, "people")?; + let payload = get_json( + &provider, + &format!("people/{person_id}/extended"), + vec![("meta", "translations".to_string())], + &format!("people lookup for {person_id}"), + ) + .await?; + let person = payload.get("data").unwrap_or(&payload); + Ok(tvdb_person_from_detail(person, &provider.language)) +} + +async fn fetch_translation_payload( + provider: &MetadataProviderSettings, + record_path: &str, + external_id: &str, + provider_language: &str, +) -> Option { + let id = parse_tvdb_external_id(external_id, record_path).ok()?; + match get_json( + provider, + &format!( + "{}/{}/translations/{}", + record_path.trim_matches('/'), + id, + provider_language + ), + Vec::new(), + &format!("{record_path} translation lookup for {external_id} [{provider_language}]"), + ) + .await + { + Ok(payload) => payload.get("data").cloned().or(Some(payload)), + Err(error) => { + log::debug!("Skipping TheTVDB translation payload: {}", error); + None + } + } +} + +fn search_result_from_value( + item: Value, + provider_language: &str, +) -> Option { + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(|value| value.to_ascii_lowercase())?; + let media_type = match item_type.as_str() { + "series" => "series", + "movie" => "movie", + _ => return None, + }; + + let external_id = object_id(&item)?.to_string(); + let title = best_title(&item, provider_language)?; + Some(MetadataSearchResult { + provider_id: MetadataProviderId::Tvdb, + external_id, + media_type: media_type.into(), + title, + overview: best_overview(&item, provider_language), + artwork_url: artwork_url(&item), + backdrop_url: backdrop_url(&item), + release_year: release_year(&item), + score: None, + }) +} + +fn movie_snapshot_from_value( + external_id: &str, + payload: &Value, + translation: Option<&Value>, + provider_language: &str, +) -> StoredMetadataSnapshot { + let enriched_payload = payload_with_translation(payload, translation); + let data = enriched_payload.get("data").unwrap_or(&enriched_payload); + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: external_id.to_string(), + media_type: Some("movie".into()), + title: best_title(data, provider_language), + overview: best_overview(data, provider_language), + artwork_url: artwork_url(data), + backdrop_url: backdrop_url(data), + release_year: release_year(data), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: Some(enriched_payload.to_string()), + } +} + +fn series_snapshot_from_value( + external_id: &str, + payload: &Value, + translation: Option<&Value>, + provider_language: &str, +) -> StoredMetadataSnapshot { + let enriched_payload = payload_with_translation(payload, translation); + let data = enriched_payload.get("data").unwrap_or(&enriched_payload); + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: external_id.to_string(), + media_type: Some("series".into()), + title: best_title(data, provider_language), + overview: best_overview(data, provider_language), + artwork_url: artwork_url(data), + backdrop_url: backdrop_url(data), + release_year: release_year(data), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: Some(enriched_payload.to_string()), + } +} + +fn season_snapshot_from_value( + show_external_id: &str, + season_number: i32, + season_external_id: &str, + payload: &Value, + translation: Option<&Value>, + provider_language: &str, +) -> StoredMetadataSnapshot { + let enriched_payload = payload_with_translation(payload, translation); + let data = enriched_payload.get("data").unwrap_or(&enriched_payload); + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: format!("series:{show_external_id}:season:{season_external_id}"), + media_type: Some("season".into()), + title: best_title(data, provider_language) + .or_else(|| Some(format!("Season {}", season_number))), + overview: best_overview(data, provider_language), + artwork_url: artwork_url(data), + backdrop_url: backdrop_url(data), + release_year: release_year(data), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: Some(enriched_payload.to_string()), + } +} + +fn episode_snapshot_from_value( + show_external_id: &str, + season_number: i32, + _episode_number: i32, + episode_external_id: &str, + payload: &Value, + translation: Option<&Value>, + provider_language: &str, +) -> StoredMetadataSnapshot { + let enriched_payload = payload_with_translation(payload, translation); + let data = enriched_payload.get("data").unwrap_or(&enriched_payload); + StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: format!( + "series:{show_external_id}:season:{season_number}:episode:{episode_external_id}" + ), + media_type: Some("episode".into()), + title: best_title(data, provider_language), + overview: best_overview(data, provider_language), + artwork_url: still_url(data).or_else(|| artwork_url(data)), + backdrop_url: backdrop_url(data), + release_year: release_year(data), + locale_key: crate::metadata::DEFAULT_METADATA_LOCALE.to_string(), + provider_locale_key: None, + provider_payload_json: Some(enriched_payload.to_string()), + } +} + +pub(crate) fn metadata_details(snapshot: &StoredMetadataSnapshot) -> ProviderMetadataDetails { + let Some(payload) = snapshot + .provider_payload_json + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + else { + return ProviderMetadataDetails::default(); + }; + let data = payload.get("data").unwrap_or(&payload); + let language = data + .get("koko_provider_language") + .and_then(Value::as_str) + .or(snapshot.provider_locale_key.as_deref()) + .unwrap_or("eng"); + let trailer = tvdb_trailer_entry(data, language); + + ProviderMetadataDetails { + external_ids: tvdb_external_ids(data, snapshot), + tagline: tvdb_tagline(data), + logo_url: tvdb_logo_url(data, language), + genres: tvdb_genres(data), + rating: data + .get("score") + .and_then(Value::as_f64) + .map(|value| value as f32), + content_rating: tvdb_content_rating(data), + trailer_title: trailer.and_then(tvdb_trailer_title), + trailer_url: trailer.and_then(tvdb_trailer_url), + people: tvdb_people_with_language(&payload, language), + ..ProviderMetadataDetails::default() + } +} + +fn tvdb_external_ids( + data: &Value, + snapshot: &StoredMetadataSnapshot, +) -> Vec { + let mut external_ids = Vec::new(); + push_external_id(&mut external_ids, "thetvdb", Some(&snapshot.external_id)); + extend_tvdb_remote_external_ids(&mut external_ids, data); + external_ids +} + +fn extend_tvdb_remote_external_ids( + external_ids: &mut Vec, + value: &Value, +) { + let Some(remote_ids) = value + .get("remoteIds") + .or_else(|| value.get("remote_ids")) + .and_then(Value::as_array) + else { + return; + }; + + for remote_id in remote_ids { + let Some(source) = tvdb_remote_id_source(remote_id) else { + continue; + }; + let external_id = remote_id + .get("id") + .or_else(|| remote_id.get("externalId")) + .or_else(|| remote_id.get("external_id")); + push_external_id_value(external_ids, &source, external_id); + } +} + +fn tvdb_remote_id_source(remote_id: &Value) -> Option { + remote_id + .get("type") + .and_then(tvdb_remote_id_type_source) + .or_else(|| { + remote_id + .get("sourceName") + .or_else(|| remote_id.get("source")) + .and_then(Value::as_str) + .and_then(tvdb_remote_id_source_name) + }) +} + +fn tvdb_remote_id_source_name(source_name: &str) -> Option { + let source_name = source_name.trim(); + TVDB_REMOTE_ID_SOURCE_TYPES + .iter() + .find(|source_type| source_type.source_name.eq_ignore_ascii_case(source_name)) + .map(|source_type| source_type.source.to_string()) +} + +fn tvdb_remote_id_type_source(value: &Value) -> Option { + let source_type = match value.as_i64() { + Some(source_type) => source_type, + None => { + let source = value.as_str()?.trim(); + match source.parse::() { + Ok(source_type) => source_type, + Err(_) => return tvdb_remote_id_source_name(source), + } + } + }; + TVDB_REMOTE_ID_SOURCE_TYPES + .iter() + .find(|source| source.id == source_type) + .map(|source| source.source.to_string()) +} + +struct TvdbRemoteIdSourceType { + id: i64, + source_name: &'static str, + source: &'static str, +} + +// Source type ids come from TheTVDB's `/sources/types` contract. +const TVDB_REMOTE_ID_SOURCE_TYPES: &[TvdbRemoteIdSourceType] = &[ + TvdbRemoteIdSourceType { + id: 2, + source_name: "IMDB", + source: "imdb", + }, + TvdbRemoteIdSourceType { + id: 3, + source_name: "TMS (Zap2It)", + source: "zap2it", + }, + TvdbRemoteIdSourceType { + id: 4, + source_name: "Official Website", + source: "official_website", + }, + TvdbRemoteIdSourceType { + id: 5, + source_name: "Facebook", + source: "facebook", + }, + TvdbRemoteIdSourceType { + id: 6, + source_name: "X", + source: "twitter", + }, + TvdbRemoteIdSourceType { + id: 7, + source_name: "Reddit", + source: "reddit", + }, + TvdbRemoteIdSourceType { + id: 8, + source_name: "Instagram", + source: "instagram", + }, + TvdbRemoteIdSourceType { + id: 9, + source_name: "Wikipedia", + source: "wikipedia", + }, + TvdbRemoteIdSourceType { + id: 10, + source_name: "Wikidata", + source: "wikidata", + }, + TvdbRemoteIdSourceType { + id: 11, + source_name: "YouTube", + source: "youtube", + }, + TvdbRemoteIdSourceType { + id: 12, + source_name: "TheMovieDB.com", + source: "tmdb", + }, + TvdbRemoteIdSourceType { + id: 13, + source_name: "EIDR", + source: "eidr", + }, +]; + +fn push_external_id_value( + external_ids: &mut Vec, + source: &str, + value: Option<&Value>, +) { + let external_id = value.and_then(|value| { + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_i64().map(|id| id.to_string())) + .or_else(|| value.as_u64().map(|id| id.to_string())) + }); + push_external_id(external_ids, source, external_id.as_deref()); +} + +fn push_external_id( + external_ids: &mut Vec, + source: &str, + external_id: Option<&str>, +) { + let Some(external_id) = external_id.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + let Some(source) = normalize_external_id_source(source) else { + return; + }; + if external_ids + .iter() + .any(|existing| existing.source == source && existing.external_id == external_id) + { + return; + } + external_ids.push(ProviderExternalId { + source, + external_id: external_id.to_string(), + }); +} + +fn tvdb_trailer_entry<'a>( + value: &'a Value, + provider_language: &str, +) -> Option<&'a Value> { + let trailers = value.get("trailers").and_then(Value::as_array)?; + trailers + .iter() + .find(|trailer| { + tvdb_trailer_url(trailer).is_some() + && tvdb_trailer_language_matches(trailer, provider_language) + }) + .or_else(|| { + trailers + .iter() + .find(|trailer| tvdb_trailer_url(trailer).is_some()) + }) +} + +fn tvdb_trailer_title(value: &Value) -> Option { + text_field(value, &["name"]) +} + +fn tvdb_trailer_url(value: &Value) -> Option { + text_field(value, &["url"]) + .as_deref() + .and_then(crate::metadata::youtube_watch_url) +} + +fn tvdb_trailer_language_matches( + value: &Value, + provider_language: &str, +) -> bool { + let provider_language = provider_language.trim(); + if provider_language.is_empty() { + return false; + } + + value + .get("language") + .and_then(Value::as_str) + .map(|language| language.eq_ignore_ascii_case(provider_language)) + .unwrap_or(false) +} + +pub(crate) async fn cache_person_assets( + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result { + let Some(payload_json) = snapshot.provider_payload_json.as_deref() else { + return Ok(snapshot.clone()); + }; + let mut payload = + serde_json::from_str::(payload_json).map_err(|error| error.to_string())?; + cache_tvdb_people_payload_images(&mut payload, snapshot, data_dir).await?; + + let mut next_snapshot = snapshot.clone(); + next_snapshot.provider_payload_json = Some(payload.to_string()); + Ok(next_snapshot) +} + +async fn cache_tvdb_people_payload_images( + payload: &mut Value, + snapshot: &StoredMetadataSnapshot, + data_dir: &str, +) -> Result<(), String> { + let data = match payload { + Value::Object(map) if map.contains_key("data") => map.get_mut("data").unwrap(), + _ => payload, + }; + let entries = if data.get("characters").is_some() { + data.get_mut("characters").and_then(Value::as_array_mut) + } else { + data.get_mut("people").and_then(Value::as_array_mut) + }; + let Some(entries) = entries else { + return Ok(()); + }; + for entry in entries { + let person = entry.get("koko_person").or_else(|| entry.get("person")); + let external_id = person + .and_then(person_external_id) + .or_else(|| tvdb_person_external_id(entry)); + let Some(external_id) = external_id else { + continue; + }; + let image_url = tvdb_person_image_url(entry, person); + let Some(image_url) = image_url else { + continue; + }; + let person_dir = managed_metadata_asset_dir( + data_dir, + snapshot.provider_id.clone(), + &external_id, + Some("people"), + &snapshot.locale_key, + ); + let cache_key = format!("{}_profile", snapshot.provider_id.as_storage_value()); + let Some(path) = try_cache_item_artwork(&image_url, &person_dir, &cache_key).await else { + continue; + }; + if let Some(map) = entry.as_object_mut() { + map.insert( + "koko_cached_image_path".into(), + Value::String(metadata_asset_db_path(data_dir, &path)), + ); + if let Some(person) = map.get_mut("koko_person").and_then(Value::as_object_mut) { + person.insert( + "koko_cached_image_path".into(), + Value::String(metadata_asset_db_path(data_dir, &path)), + ); + } else if let Some(person) = map.get_mut("person").and_then(Value::as_object_mut) { + person.insert( + "koko_cached_image_path".into(), + Value::String(metadata_asset_db_path(data_dir, &path)), + ); + } + } + } + Ok(()) +} + +fn tvdb_tagline(value: &Value) -> Option { + text_field(value, &["tagline"]).or_else(|| { + value + .get("koko_translation") + .and_then(|translation| text_field(translation, &["tagline"])) + }) +} + +fn tvdb_logo_url( + value: &Value, + provider_language: &str, +) -> Option { + tvdb_artwork_url_with_language(value, &[23, 25], provider_language) +} + +fn tvdb_artwork_url_with_language( + value: &Value, + preferred_types: &[i64], + provider_language: &str, +) -> Option { + let preferred = tvdb_language_preference(provider_language); + let artworks = value + .get("artworks") + .or_else(|| value.get("artwork")) + .and_then(Value::as_array)?; + preferred_types.iter().find_map(|preferred_type| { + artworks + .iter() + .filter(|artwork| artwork.get("type").and_then(Value::as_i64) == Some(*preferred_type)) + .filter(|artwork| { + artwork + .get("language") + .or_else(|| artwork.get("languageCode")) + .or_else(|| artwork.get("iso_639_1")) + .and_then(Value::as_str) + .map(|language| { + preferred + .iter() + .any(|entry| language.eq_ignore_ascii_case(entry)) + }) + .unwrap_or(true) + }) + .max_by(|left, right| { + let left_format_rank = tvdb_artwork_image_url(left) + .map(image_format_preference_rank) + .unwrap_or(0); + let right_format_rank = tvdb_artwork_image_url(right) + .map(image_format_preference_rank) + .unwrap_or(0); + let left_score = left.get("score").and_then(Value::as_f64).unwrap_or(0.0); + let right_score = right.get("score").and_then(Value::as_f64).unwrap_or(0.0); + left_format_rank.cmp(&right_format_rank).then_with(|| { + left_score + .partial_cmp(&right_score) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }) + .and_then(tvdb_artwork_image_url) + .map(ToOwned::to_owned) + }) +} + +fn tvdb_artwork_image_url(artwork: &Value) -> Option<&str> { + artwork + .get("image") + .or_else(|| artwork.get("thumbnail")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) +} + +fn tvdb_genres(value: &Value) -> Vec { + let mut genres = Vec::new(); + collect_tvdb_genres(value.get("genres"), &mut genres); + genres +} + +fn collect_tvdb_genres( + value: Option<&Value>, + genres: &mut Vec, +) { + let Some(value) = value else { + return; + }; + match value { + Value::Array(entries) => { + for entry in entries { + let genre = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + entry + .get("name") + .or_else(|| entry.get("label")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }); + if let Some(genre) = genre { + push_unique_genre(genres, genre); + } + } + } + Value::Object(map) => { + for value in map.values() { + if let Some(genre) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + { + push_unique_genre(genres, genre); + } + } + } + Value::String(value) => push_unique_genre(genres, value.trim().to_string()), + _ => {} + } +} + +fn push_unique_genre( + genres: &mut Vec, + genre: String, +) { + if !genre.is_empty() + && !genres + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&genre)) + { + genres.push(genre); + } +} + +fn tvdb_content_rating(value: &Value) -> Option { + value + .get("contentRatings") + .or_else(|| value.get("content_ratings")) + .and_then(Value::as_array) + .and_then(|ratings| { + ratings + .iter() + .find(|rating| { + rating.get("country").and_then(Value::as_str) == Some("usa") + || rating.get("country").and_then(Value::as_str) == Some("us") + || rating.get("country").and_then(Value::as_str) == Some("US") + }) + .or_else(|| ratings.first()) + }) + .and_then(|rating| rating.get("name").or_else(|| rating.get("fullName"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn tvdb_people_with_language( + payload: &Value, + provider_language: &str, +) -> Vec { + let data = payload.get("data").unwrap_or(payload); + let characters = data + .get("characters") + .or_else(|| data.get("people")) + .and_then(Value::as_array); + let Some(characters) = characters else { + return Vec::new(); + }; + + sort_and_dedupe_people( + characters + .iter() + .enumerate() + .filter_map(|(index, entry)| { + let person = entry.get("koko_person").or_else(|| entry.get("person")); + let person_for_details = person.unwrap_or(entry); + let name = person + .and_then(person_name) + .or_else(|| { + text_field( + entry, + &[ + "personName", + "peopleName", + "actorName", + ], + ) + }) + .filter(|name| { + text_field(entry, &["name"]) + .map(|character| character != *name) + .unwrap_or(true) + })?; + let role = + text_field(entry, &["type", "role", "job"]).or_else(|| Some("Actor".into())); + let character_name = text_field( + entry, + &[ + "name", + "character", + "characterName", + ], + ) + .filter(|character| character != &name); + Some(ProviderMetadataPerson { + external_id: person + .and_then(person_external_id) + .or_else(|| tvdb_person_external_id(entry)), + external_ids: tvdb_person_external_ids(entry, person), + name, + known_for: Vec::new(), + biography: tvdb_person_biography(person_for_details, provider_language), + gender: text_field(person_for_details, &["gender"]) + .or_else(|| text_field(entry, &["gender"])), + birthday: text_field(person_for_details, &["birth"]), + deathday: text_field(person_for_details, &["death"]), + birth_place: text_field(person_for_details, &["birthPlace"]), + department: Some(if role.as_deref() == Some("Actor") { + "Cast".into() + } else { + "Crew".into() + }), + role, + character_name, + profile_url: person + .and_then(person_external_id) + .or_else(|| tvdb_person_external_id(entry)) + .map(|id| format!("https://thetvdb.com/people/{id}")), + image_url: tvdb_person_image_url(entry, person), + cached_image_path: text_field(person_for_details, &["koko_cached_image_path"]) + .or_else(|| text_field(entry, &["koko_cached_image_path"])), + sort_order: i32::try_from(index).unwrap_or(i32::MAX), + }) + }) + .collect(), + ) +} + +fn tvdb_person_from_detail( + person: &Value, + provider_language: &str, +) -> Option { + let name = person_name(person)?; + let external_id = person_external_id(person); + Some(ProviderMetadataPerson { + external_id: external_id.clone(), + external_ids: tvdb_person_external_ids(person, None), + name, + known_for: Vec::new(), + biography: tvdb_person_biography(person, provider_language), + gender: text_field(person, &["gender"]), + birthday: text_field(person, &["birth"]), + deathday: text_field(person, &["death"]), + birth_place: text_field(person, &["birthPlace"]), + role: None, + department: None, + character_name: None, + profile_url: external_id.map(|id| format!("https://thetvdb.com/people/{id}")), + image_url: tvdb_person_image_url(&Value::Null, Some(person)), + cached_image_path: text_field(person, &["koko_cached_image_path"]), + sort_order: 0, + }) +} + +fn tvdb_person_external_ids( + entry: &Value, + person: Option<&Value>, +) -> Vec { + let person = person + .or_else(|| entry.get("koko_person")) + .or_else(|| entry.get("person")); + let mut external_ids = Vec::new(); + let primary_external_id = person + .and_then(person_external_id) + .or_else(|| tvdb_person_external_id(entry)); + push_external_id(&mut external_ids, "thetvdb", primary_external_id.as_deref()); + extend_tvdb_remote_external_ids(&mut external_ids, entry); + if let Some(person) = person { + extend_tvdb_remote_external_ids(&mut external_ids, person); + } + external_ids +} + +fn tvdb_person_biography( + value: &Value, + provider_language: &str, +) -> Option { + let preferred = tvdb_requested_language_preference(provider_language); + translated_text_for_languages(value.get("biographies"), &preferred, &["biography"]).or_else( + || { + value.get("translations").and_then(|translations| { + translated_text_for_languages( + translations.get("overviewTranslations"), + &preferred, + &["overview"], + ) + }) + }, + ) +} + +fn person_name(value: &Value) -> Option { + text_field( + value, + &[ + "name", + "original_name", + "fullName", + ], + ) +} + +fn person_external_id(value: &Value) -> Option { + value + .get("id") + .or_else(|| value.get("peopleId")) + .or_else(|| value.get("personId")) + .and_then(|id| { + id.as_i64() + .map(|id| id.to_string()) + .or_else(|| id.as_str().map(str::to_string)) + }) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) +} + +fn tvdb_person_external_id(value: &Value) -> Option { + value + .get("peopleId") + .or_else(|| value.get("personId")) + .and_then(|id| { + id.as_i64() + .map(|id| id.to_string()) + .or_else(|| id.as_str().map(str::to_string)) + }) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .or_else(|| person_external_id(value)) +} + +fn tvdb_person_image_url( + entry: &Value, + person: Option<&Value>, +) -> Option { + person + .and_then(|person| { + tvdb_image_field( + person, + &[ + "image", + "image_url", + "thumbnail", + ], + ) + }) + .or_else(|| { + tvdb_image_field( + entry, + &[ + "personImgURL", + "peopleImgURL", + "personImage", + "peopleImage", + ], + ) + }) + .or_else(|| { + if entry + .get("peopleId") + .or_else(|| entry.get("personId")) + .is_some() + || person.is_some() + { + None + } else { + tvdb_image_field( + entry, + &[ + "image", + "image_url", + "thumbnail", + ], + ) + } + }) +} + +fn tvdb_image_field( + value: &Value, + keys: &[&str], +) -> Option { + text_field(value, keys).map(|url| { + if url.starts_with("http://") || url.starts_with("https://") { + url + } else if url.starts_with('/') { + format!("https://artworks.thetvdb.com{url}") + } else { + format!("https://artworks.thetvdb.com/{url}") + } + }) +} + +fn sort_and_dedupe_people(people: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut people = people + .into_iter() + .filter(|person| { + let key = format!( + "{}:{}:{}", + person.external_id.as_deref().unwrap_or(""), + person.name.to_ascii_lowercase(), + person.role.as_deref().unwrap_or("") + ); + seen.insert(key) + }) + .collect::>(); + people.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.department.cmp(&right.department)) + .then_with(|| left.name.cmp(&right.name)) + }); + people.truncate(80); + people +} + +fn payload_with_translation( + payload: &Value, + translation: Option<&Value>, +) -> Value { + let mut payload = payload.clone(); + if let Some(data) = payload.get_mut("data").and_then(Value::as_object_mut) { + if let Some(translation) = translation { + data.insert("koko_translation".into(), translation.clone()); + if let Some(language) = translation + .get("language") + .or_else(|| translation.get("languageCode")) + .and_then(Value::as_str) + { + data.insert( + "koko_provider_language".into(), + Value::String(language.to_string()), + ); + } + } + } else if let Some(map) = payload.as_object_mut() { + if let Some(translation) = translation { + map.insert("koko_translation".into(), translation.clone()); + if let Some(language) = translation + .get("language") + .or_else(|| translation.get("languageCode")) + .and_then(Value::as_str) + { + map.insert( + "koko_provider_language".into(), + Value::String(language.to_string()), + ); + } + } + } + payload +} + +fn object_id(value: &Value) -> Option { + value + .get("id") + .and_then(Value::as_i64) + .and_then(|id| i32::try_from(id).ok()) + .or_else(|| { + value + .get("id") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) + .or_else(|| { + value + .get("tvdb_id") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) + .or_else(|| { + value + .get("objectID") + .and_then(Value::as_str) + .and_then(|id| id.parse::().ok()) + }) +} + +fn best_title( + value: &Value, + provider_language: &str, +) -> Option { + let preferred = tvdb_language_preference(provider_language); + value + .get("koko_translation") + .and_then(|translation| text_field(translation, &["name", "title"])) + .or_else(|| { + value + .get("translations") + .and_then(|translations| translation_record(translations, &preferred)) + .and_then(|translation| text_field(translation, &["name", "title"])) + }) + .or_else(|| { + value + .get("name") + .or_else(|| value.get("title")) + .or_else(|| value.get("name_translated")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + value + .get("translations") + .and_then(Value::as_object) + .and_then(|translations| { + preferred + .iter() + .find_map(|key| translations.get(key)) + .or_else(|| translations.values().next()) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + value + .get("translations") + .and_then(Value::as_array) + .and_then(|translations| { + translations + .iter() + .find(|translation| { + translation + .get("language") + .or_else(|| translation.get("languageCode")) + .and_then(Value::as_str) + .map(|language| { + matches!( + language.to_ascii_lowercase().as_str(), + "eng" | "en" | "english" + ) + }) + .unwrap_or(false) + }) + .or_else(|| translations.first()) + }) + .and_then(|translation| { + translation.get("name").or_else(|| translation.get("title")) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn best_overview( + value: &Value, + provider_language: &str, +) -> Option { + let preferred = tvdb_language_preference(provider_language); + value + .get("koko_translation") + .and_then(|translation| text_field(translation, &["overview", "description"])) + .or_else(|| { + value + .get("translations") + .and_then(|translations| translation_record_for_languages(translations, &preferred)) + .and_then(|translation| text_field(translation, &["overview", "description"])) + }) + .or_else(|| translated_overview_for_languages(value.get("overviews"), &preferred)) + .or_else(|| { + translated_overview_for_languages(value.get("overviewTranslations"), &preferred) + }) + .or_else(|| translated_overview_for_languages(value.get("translations"), &preferred)) + .or_else(|| { + (!has_overview_translation_metadata(value)) + .then(|| { + text_field( + value, + &[ + "overview", + "description", + "shortDescription", + "longDescription", + ], + ) + }) + .flatten() + }) +} + +fn has_overview_translation_metadata(value: &Value) -> bool { + [ + "koko_translation", + "translations", + "overviews", + "overviewTranslations", + ] + .iter() + .any(|key| value.get(*key).is_some()) +} + +fn translation_record<'a>( + value: &'a Value, + preferred_keys: &[String], +) -> Option<&'a Value> { + if let Some(map) = value.as_object() { + return preferred_keys + .iter() + .find_map(|key| map.get(key)) + .or_else(|| map.values().next()); + } + + value.as_array().and_then(|translations| { + preferred_keys + .iter() + .find_map(|key| { + translations.iter().find(|translation| { + translation + .get("language") + .or_else(|| translation.get("languageCode")) + .or_else(|| translation.get("iso_639_1")) + .and_then(Value::as_str) + .map(|language| language.eq_ignore_ascii_case(key)) + .unwrap_or(false) + }) + }) + .or_else(|| translations.first()) + }) +} + +fn translation_record_for_languages<'a>( + value: &'a Value, + preferred_keys: &[String], +) -> Option<&'a Value> { + if let Some(map) = value.as_object() { + return preferred_keys.iter().find_map(|key| map.get(key)); + } + + value.as_array().and_then(|translations| { + preferred_keys.iter().find_map(|key| { + translations.iter().find(|translation| { + translation + .get("language") + .or_else(|| translation.get("languageCode")) + .or_else(|| translation.get("iso_639_1")) + .and_then(Value::as_str) + .map(|language| language.eq_ignore_ascii_case(key)) + .unwrap_or(false) + }) + }) + }) +} + +fn text_field( + value: &Value, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|overview| !overview.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn translated_overview_for_languages( + value: Option<&Value>, + preferred_keys: &[String], +) -> Option { + translated_text_for_languages(value, preferred_keys, &["overview", "description"]) +} + +fn translated_text_for_languages( + value: Option<&Value>, + preferred_keys: &[String], + text_keys: &[&str], +) -> Option { + let value = value?; + if let Some(map) = value.as_object() { + return preferred_keys.iter().find_map(|key| { + map.get(key) + .and_then(|value| translation_text_value(value, text_keys)) + }); + } + + value.as_array().and_then(|translations| { + preferred_keys.iter().find_map(|key| { + translations.iter().find_map(|translation| { + let language = translation + .get("language") + .or_else(|| translation.get("languageCode")) + .or_else(|| translation.get("iso_639_1")) + .and_then(Value::as_str)?; + language + .eq_ignore_ascii_case(key.as_str()) + .then(|| translation_text_value(translation, text_keys)) + .flatten() + }) + }) + }) +} + +fn tvdb_requested_language_preference(provider_language: &str) -> Vec { + let normalized = provider_language.trim().to_ascii_lowercase(); + let mut languages = Vec::new(); + push_language_preference(&mut languages, &normalized); + match normalized.as_str() { + "eng" | "en" | "english" => { + push_language_preference(&mut languages, "eng"); + push_language_preference(&mut languages, "en"); + push_language_preference(&mut languages, "english"); + } + "spa" | "es" | "spanish" => { + push_language_preference(&mut languages, "spa"); + push_language_preference(&mut languages, "es"); + } + "fra" | "fr" | "french" => { + push_language_preference(&mut languages, "fra"); + push_language_preference(&mut languages, "fr"); + } + "deu" | "de" | "ger" | "german" => { + push_language_preference(&mut languages, "deu"); + push_language_preference(&mut languages, "de"); + } + "ita" | "it" | "italian" => { + push_language_preference(&mut languages, "ita"); + push_language_preference(&mut languages, "it"); + } + "jpn" | "ja" | "japanese" => { + push_language_preference(&mut languages, "jpn"); + push_language_preference(&mut languages, "ja"); + } + "por" | "pt" | "portuguese" => { + push_language_preference(&mut languages, "por"); + push_language_preference(&mut languages, "pt"); + } + _ => {} + } + languages +} + +fn tvdb_language_preference(provider_language: &str) -> Vec { + let mut languages = tvdb_requested_language_preference(provider_language); + for language in ["eng", "en", "english"] { + push_language_preference(&mut languages, language); + } + languages +} + +fn push_language_preference( + languages: &mut Vec, + language: &str, +) { + let language = language.trim().to_ascii_lowercase(); + if !language.is_empty() && !languages.iter().any(|entry| entry == &language) { + languages.push(language); + } +} + +fn translation_text_value( + value: &Value, + text_keys: &[&str], +) -> Option { + value + .as_str() + .map(str::trim) + .filter(|overview| !overview.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| text_field(value, text_keys)) +} + +fn artwork_url(value: &Value) -> Option { + tvdb_artwork_url(value, &[14, 2, 7]).or_else(|| { + value + .get("image") + .or_else(|| value.get("image_url")) + .or_else(|| value.get("poster")) + .or_else(|| value.get("thumbnail")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn backdrop_url(value: &Value) -> Option { + tvdb_artwork_url(value, &[15, 3, 8]).or_else(|| { + value + .get("artworks") + .and_then(Value::as_array) + .and_then(|artworks| { + artworks.iter().find_map(|artwork| { + artwork + .get("image") + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) + }) + }) + }) +} + +fn tvdb_artwork_url( + value: &Value, + preferred_types: &[i64], +) -> Option { + let artworks = value + .get("artworks") + .or_else(|| value.get("artwork")) + .and_then(Value::as_array)?; + preferred_types.iter().find_map(|preferred_type| { + artworks + .iter() + .filter(|artwork| artwork.get("type").and_then(Value::as_i64) == Some(*preferred_type)) + .max_by(|left, right| { + let left_score = left.get("score").and_then(Value::as_f64).unwrap_or(0.0); + let right_score = right.get("score").and_then(Value::as_f64).unwrap_or(0.0); + left_score + .partial_cmp(&right_score) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .and_then(|artwork| { + artwork + .get("image") + .or_else(|| artwork.get("thumbnail")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) + }) + }) +} + +fn still_url(value: &Value) -> Option { + value + .get("image") + .or_else(|| value.get("image_url")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) +} + +fn release_year(value: &Value) -> Option { + value + .get("year") + .and_then(Value::as_i64) + .and_then(|year| i32::try_from(year).ok()) + .or_else(|| { + value + .get("year") + .and_then(Value::as_str) + .and_then(|year| year.parse::().ok()) + }) + .or_else(|| { + value + .get("firstAired") + .or_else(|| value.get("releaseDate")) + .or_else(|| value.get("first_release")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .and_then(|value| extract_release_year(Some(value))) + }) +} + +fn season_number(value: &Value) -> Option { + value + .get("number") + .or_else(|| value.get("seasonNumber")) + .and_then(Value::as_i64) + .and_then(|number| i32::try_from(number).ok()) +} + +fn episode_number(value: &Value) -> Option { + value + .get("number") + .or_else(|| value.get("episodeNumber")) + .and_then(Value::as_i64) + .and_then(|number| i32::try_from(number).ok()) +} + +#[cfg(test)] +mod tests { + use super::{ + artwork_url, + backdrop_url, + best_overview, + metadata_details, + metadata_item_kind, + movie_snapshot_from_value, + search_result_from_value, + tvdb_logo_url, + tvdb_people_with_language, + }; + use crate::metadata::MetadataItemKind; + use serde_json::json; + + #[test] + fn tvdb_search_result_accepts_object_id_and_translations() { + let result = search_result_from_value( + json!({ + "type": "movie", + "objectID": "901", + "translations": { + "eng": "Top Gun: Maverick" + }, + "overviews": { + "eng": "After more than thirty years of service..." + }, + "year": "2022", + "image_url": "https://example.test/poster.jpg" + }), + "eng", + ) + .expect("expected TVDB search result to parse"); + + assert_eq!(result.external_id, "901"); + assert_eq!(result.media_type, "movie"); + assert_eq!(result.title, "Top Gun: Maverick"); + assert_eq!(result.release_year, Some(2022)); + assert_eq!( + result.overview.as_deref(), + Some("After more than thirty years of service...") + ); + } + + #[test] + fn tvdb_search_result_uses_requested_language_for_overview() { + let result = search_result_from_value( + json!({ + "type": "movie", + "objectID": "711", + "name": "T-34", + "overview": "Russian source overview.", + "overviews": { + "eng": "English translated overview.", + "rus": "Russian translated overview." + }, + "year": "2018" + }), + "eng", + ) + .expect("expected TVDB search result to parse"); + + assert_eq!( + result.overview.as_deref(), + Some("English translated overview.") + ); + } + + #[test] + fn tvdb_search_result_does_not_fall_back_to_unrequested_overview_language() { + let result = search_result_from_value( + json!({ + "type": "movie", + "objectID": "711", + "name": "T-34", + "overview": "Russian source overview.", + "overviewTranslations": ["rus"], + "year": "2018" + }), + "eng", + ) + .expect("expected TVDB search result to parse"); + + assert_eq!(result.overview, None); + } + + #[test] + fn tvdb_metadata_item_kind_uses_exact_provider_media_types() { + assert_eq!(metadata_item_kind(Some("movie")), MetadataItemKind::Movie); + assert_eq!(metadata_item_kind(Some("series")), MetadataItemKind::Show); + assert_eq!(metadata_item_kind(Some("season")), MetadataItemKind::Season); + assert_eq!( + metadata_item_kind(Some("episode")), + MetadataItemKind::Episode + ); + assert_eq!(metadata_item_kind(Some("people")), MetadataItemKind::Person); + assert_eq!(metadata_item_kind(Some("tv")), MetadataItemKind::Item); + assert_eq!(metadata_item_kind(Some("person")), MetadataItemKind::Item); + assert_eq!(metadata_item_kind(Some("actor")), MetadataItemKind::Item); + } + + #[test] + fn tvdb_search_result_accepts_series_type() { + let result = search_result_from_value( + json!({ + "type": "series", + "tvdb_id": "42", + "name": "Example Show" + }), + "eng", + ) + .expect("expected TVDB show search result to parse"); + + assert_eq!(result.external_id, "42"); + assert_eq!(result.media_type, "series"); + assert_eq!(result.title, "Example Show"); + } + + #[test] + fn tvdb_search_result_rejects_media_type_aliases() { + assert!( + search_result_from_value( + json!({ + "type": "show", + "tvdb_id": "42", + "name": "Example Show" + }), + "eng", + ) + .is_none() + ); + assert!( + search_result_from_value( + json!({ + "type": "feature film", + "objectID": "901", + "name": "Example Movie" + }), + "eng", + ) + .is_none() + ); + } + + #[test] + fn tvdb_movie_snapshot_prefers_translation_payload_for_overview_and_tagline() { + let payload = json!({ + "data": { + "id": 901, + "name": "Provider Name", + "overviewTranslations": ["eng", "rus"], + "translations": { + "rus": "rus" + }, + "artworks": [ + { "type": 14, "image": "https://example.test/poster.jpg", "score": 1.0 }, + { "type": 15, "image": "https://example.test/backdrop.jpg", "score": 1.0 }, + { "type": 25, "image": "https://example.test/logo.png", "score": 1.0 } + ], + "genres": [{ "name": "Action" }] + } + }); + let translation = json!({ + "language": "eng", + "name": "Translated Name", + "overview": "Translated overview.", + "tagline": "Translated tagline." + }); + + let snapshot = movie_snapshot_from_value("901", &payload, Some(&translation), "eng"); + assert_eq!(snapshot.title.as_deref(), Some("Translated Name")); + assert_eq!(snapshot.overview.as_deref(), Some("Translated overview.")); + assert_eq!( + snapshot.artwork_url.as_deref(), + Some("https://example.test/poster.jpg") + ); + assert_eq!( + snapshot.backdrop_url.as_deref(), + Some("https://example.test/backdrop.jpg") + ); + assert!( + snapshot + .provider_payload_json + .as_deref() + .is_some_and(|payload| payload.contains("Translated tagline.")) + ); + } + + #[test] + fn tvdb_overview_does_not_use_language_code_names() { + let payload = json!({ + "translations": [ + { "language": "rus", "name": "rus" } + ] + }); + + assert_eq!(best_overview(&payload, "eng"), None); + } + + #[test] + fn tvdb_artwork_uses_documented_type_ids() { + let payload = json!({ + "artworks": [ + { "type": 25, "image": "https://example.test/logo.png", "score": 9.0 }, + { "type": 14, "image": "https://example.test/poster.jpg", "score": 4.0 }, + { "type": 15, "image": "https://example.test/backdrop.jpg", "score": 6.0 } + ] + }); + + assert_eq!( + artwork_url(&payload).as_deref(), + Some("https://example.test/poster.jpg") + ); + assert_eq!( + backdrop_url(&payload).as_deref(), + Some("https://example.test/backdrop.jpg") + ); + } + + #[test] + fn tvdb_logo_url_prefers_svg_over_higher_scored_png() { + let payload = json!({ + "artworks": [ + { + "type": 25, + "image": "https://example.test/logo.png", + "language": "eng", + "score": 99.0, + }, + { + "type": 25, + "image": "https://example.test/logo.svg", + "language": "eng", + "score": 1.0, + } + ] + }); + + assert_eq!( + tvdb_logo_url(&payload, "eng").as_deref(), + Some("https://example.test/logo.svg") + ); + } + + #[test] + fn tvdb_logo_url_falls_back_to_png_when_svg_missing() { + let payload = json!({ + "artworks": [ + { + "type": 25, + "image": "https://example.test/logo.jpg", + "language": "eng", + "score": 99.0, + }, + { + "type": 25, + "image": "https://example.test/logo.png", + "language": "eng", + "score": 1.0, + } + ] + }); + + assert_eq!( + tvdb_logo_url(&payload, "eng").as_deref(), + Some("https://example.test/logo.png") + ); + } + + #[test] + fn tvdb_metadata_details_extracts_preferred_youtube_trailer() { + let payload = json!({ + "data": { + "id": 901, + "name": "Example Movie", + "trailers": [ + { + "name": "Spanish Trailer", + "language": "spa", + "url": "https://youtu.be/aaaaaaaaaaa" + }, + { + "name": "Official Trailer", + "language": "eng", + "url": "https://www.youtube.com/embed/vKQi3bBA1y8?rel=0" + }, + { + "name": "Provider Hosted Trailer", + "language": "eng", + "url": "https://example.test/trailer.mp4" + } + ] + } + }); + let snapshot = movie_snapshot_from_value("901", &payload, None, "eng"); + let details = metadata_details(&snapshot); + + assert_eq!(details.trailer_title.as_deref(), Some("Official Trailer")); + assert_eq!( + details.trailer_url.as_deref(), + Some("https://www.youtube.com/watch?v=vKQi3bBA1y8") + ); + } + + #[test] + fn tvdb_metadata_details_collects_remote_ids() { + let payload = json!({ + "data": { + "id": 901, + "name": "Example Movie", + "remoteIds": [ + { + "sourceName": "IMDB", + "id": "tt1745960" + }, + { + "sourceName": "TheMovieDB.com", + "id": 361743 + }, + { + "sourceName": "Wikidata", + "id": "Q105452506" + }, + { + "type": 2, + "id": "tt7654321" + }, + { + "type": 12, + "id": 7654321 + } + ] + } + }); + let snapshot = movie_snapshot_from_value("901", &payload, None, "eng"); + let details = metadata_details(&snapshot); + + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "thetvdb" && id.external_id == "901" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "imdb" && id.external_id == "tt1745960" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "tmdb" && id.external_id == "361743" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "wikidata" && id.external_id == "Q105452506" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "imdb" && id.external_id == "tt7654321" }) + ); + assert!( + details + .external_ids + .iter() + .any(|id| { id.source == "tmdb" && id.external_id == "7654321" }) + ); + } + + #[test] + fn tvdb_people_use_person_name_not_character_name() { + let payload = json!({ + "data": { + "characters": [ + { + "id": 12242840, + "name": "Brian Flanagan", + "image": "https://example.test/character-brian-flanagan.jpg", + "peopleId": 254032, + "peopleType": "Actor", + "personName": "Tom Cruise", + "personImgURL": "https://example.test/person-tom-cruise-fallback.jpg", + "koko_person": { + "id": 254032, + "name": "Tom Cruise", + "image": "https://example.test/person-tom-cruise.jpg", + "remoteIds": [ + { + "sourceName": "IMDB", + "id": "nm0000129" + } + ] + } + } + ] + } + }); + + let people = tvdb_people_with_language(&payload, "eng"); + + assert_eq!(people.len(), 1); + assert_eq!(people[0].name, "Tom Cruise"); + assert_eq!(people[0].character_name.as_deref(), Some("Brian Flanagan")); + assert_eq!(people[0].external_id.as_deref(), Some("254032")); + assert_eq!( + people[0].image_url.as_deref(), + Some("https://example.test/person-tom-cruise.jpg") + ); + assert!( + people[0] + .external_ids + .iter() + .any(|id| { id.source == "thetvdb" && id.external_id == "254032" }) + ); + assert!( + people[0] + .external_ids + .iter() + .any(|id| { id.source == "imdb" && id.external_id == "nm0000129" }) + ); + } + + #[test] + fn tvdb_people_use_shallow_character_people_without_extended_person_payload() { + let payload = json!({ + "data": { + "characters": [ + { + "id": 12242840, + "name": "Brian Flanagan", + "image": "https://example.test/character-brian-flanagan.jpg", + "peopleId": 254032, + "peopleType": "Actor", + "personName": "Tom Cruise", + "personImgURL": "https://example.test/person-tom-cruise-fallback.jpg" + } + ] + } + }); + + let people = tvdb_people_with_language(&payload, "eng"); + + assert_eq!(people.len(), 1); + assert_eq!(people[0].name, "Tom Cruise"); + assert_eq!(people[0].biography, None); + assert_eq!(people[0].character_name.as_deref(), Some("Brian Flanagan")); + assert_eq!(people[0].external_id.as_deref(), Some("254032")); + assert_eq!( + people[0].image_url.as_deref(), + Some("https://example.test/person-tom-cruise-fallback.jpg") + ); + } + + #[test] + fn tvdb_people_use_localized_person_biographies() { + let payload = json!({ + "data": { + "koko_provider_language": "spa", + "characters": [ + { + "id": 12242840, + "name": "Brian Flanagan", + "peopleId": 254032, + "personName": "Tom Cruise", + "koko_person": { + "id": 254032, + "name": "Tom Cruise", + "biographies": [ + { + "language": "eng", + "biography": "English biography." + }, + { + "language": "spa", + "biography": "Biografia en espanol." + } + ], + "birth": "1962-07-03", + "death": "2099-01-01", + "birthPlace": "Syracuse, New York, USA" + } + } + ] + } + }); + + let people = tvdb_people_with_language(&payload, "spa"); + + assert_eq!(people.len(), 1); + assert_eq!( + people[0].biography.as_deref(), + Some("Biografia en espanol.") + ); + assert_eq!(people[0].birthday.as_deref(), Some("1962-07-03")); + assert_eq!(people[0].deathday.as_deref(), Some("2099-01-01")); + assert_eq!( + people[0].birth_place.as_deref(), + Some("Syracuse, New York, USA") + ); + } + + #[test] + fn tvdb_metadata_details_use_provider_locale_for_person_biographies() { + let payload = json!({ + "data": { + "id": 901, + "name": "Cocktail", + "characters": [ + { + "name": "Brian Flanagan", + "peopleId": 254032, + "personName": "Tom Cruise", + "koko_person": { + "id": 254032, + "name": "Tom Cruise", + "biographies": [ + { + "language": "eng", + "biography": "English biography." + }, + { + "language": "spa", + "biography": "Biografia en espanol." + } + ] + } + } + ] + } + }); + let mut snapshot = movie_snapshot_from_value("901", &payload, None, "eng"); + snapshot.provider_locale_key = Some("spa".into()); + + let details = metadata_details(&snapshot); + + assert_eq!(details.people.len(), 1); + assert_eq!( + details.people[0].biography.as_deref(), + Some("Biografia en espanol.") + ); + } + + #[test] + fn tvdb_people_use_people_translation_overview_when_biography_records_are_missing() { + let payload = json!({ + "data": { + "characters": [ + { + "name": "Brian Flanagan", + "peopleId": 254032, + "personName": "Tom Cruise", + "koko_person": { + "id": 254032, + "name": "Tom Cruise", + "translations": { + "overviewTranslations": [ + { + "language": "eng", + "overview": "English translated overview." + }, + { + "language": "spa", + "overview": "Resumen biografico en espanol." + } + ] + } + } + } + ] + } + }); + + let people = tvdb_people_with_language(&payload, "spa"); + + assert_eq!(people.len(), 1); + assert_eq!( + people[0].biography.as_deref(), + Some("Resumen biografico en espanol.") + ); + } + + #[test] + fn tvdb_people_do_not_fall_back_to_unrequested_biography_languages() { + let payload = json!({ + "data": { + "characters": [ + { + "name": "Brian Flanagan", + "peopleId": 254032, + "personName": "Tom Cruise", + "koko_person": { + "id": 254032, + "name": "Tom Cruise", + "biographies": [ + { + "language": "fra", + "biography": "Biographie francaise." + } + ], + "translations": { + "overviewTranslations": [ + { + "language": "deu", + "overview": "Deutsche Biografie." + } + ] + } + } + } + ] + } + }); + + let people = tvdb_people_with_language(&payload, "spa"); + + assert_eq!(people.len(), 1); + assert_eq!(people[0].biography, None); + } +} diff --git a/crates/server/src/scanner/books.rs b/crates/server/src/scanner/books.rs new file mode 100644 index 00000000..6e763aed --- /dev/null +++ b/crates/server/src/scanner/books.rs @@ -0,0 +1,44 @@ +//! Book scanner rules. + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::directory::{ + self, + ScannerRules, +}; +use crate::scanner::{ + LibraryInspection, + ScannerSink, +}; + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + directory::scan_with_rules( + library, + ScannerRules::typed( + MediaLibraryScanner::Books, + MediaLibraryKind::Books, + |_, title| title.to_string(), + ), + ) +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + directory::scan_with_rules_streaming( + library, + ScannerRules::typed( + MediaLibraryScanner::Books, + MediaLibraryKind::Books, + |_, title| title.to_string(), + ), + sink, + ) +} diff --git a/crates/server/src/scanner/directory.rs b/crates/server/src/scanner/directory.rs new file mode 100644 index 00000000..a73e3007 --- /dev/null +++ b/crates/server/src/scanner/directory.rs @@ -0,0 +1,595 @@ +//! Shared directory scanner and common scanner hashing. + +use std::collections::HashSet; +use std::convert::Infallible; +use std::fs; +use std::io; +use std::path::{ + Path, + PathBuf, +}; +use std::time::UNIX_EPOCH; + +use imohash::Hasher as ImoHasher; + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::{ + DiscoveredMediaFile, + FileHashCandidate, + LibraryInspection, + LibraryScanStatus, + LibraryScanSummary, + ScannerSink, +}; + +#[derive(Debug, Default)] +pub(crate) struct FileCounters { + total_files: u64, + video_files: u64, + audio_files: u64, + image_files: u64, + book_files: u64, + other_files: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileKind { + Video, + Audio, + Image, + Book, + Other, +} + +impl FileKind { + fn as_storage_value(&self) -> &'static str { + match self { + FileKind::Video => "video", + FileKind::Audio => "audio", + FileKind::Image => "image", + FileKind::Book => "book", + FileKind::Other => "other", + } + } +} + +pub(crate) struct ScannerRules { + scanner: MediaLibraryScanner, + include_kind: MediaLibraryKind, + default_title: fn(&str, &str) -> String, +} + +impl ScannerRules { + pub(crate) fn for_directory(library_kind: &MediaLibraryKind) -> Self { + Self { + scanner: MediaLibraryScanner::Directory, + include_kind: library_kind.clone(), + default_title: |_, title| title.to_string(), + } + } + + pub(crate) fn typed( + scanner: MediaLibraryScanner, + include_kind: MediaLibraryKind, + default_title: fn(&str, &str) -> String, + ) -> Self { + Self { + scanner, + include_kind, + default_title, + } + } +} + +struct ScannerProgress { + library_name: String, + root_path: String, + hashed_files: u64, + reused_hashes: u64, + next_log_at: u64, +} + +impl ScannerProgress { + fn new( + library_name: &str, + root_path: &str, + ) -> Self { + Self { + library_name: library_name.to_string(), + root_path: root_path.to_string(), + hashed_files: 0, + reused_hashes: 0, + next_log_at: 100, + } + } + + fn record_hashed_file(&mut self) { + self.hashed_files += 1; + if self.hashed_files >= self.next_log_at { + log::info!( + "Calculated {} scanner hash(es) for library {} under {}", + self.hashed_files, + self.library_name, + self.root_path + ); + self.next_log_at += 100; + } + } + + fn record_reused_hash(&mut self) { + self.reused_hashes += 1; + let processed_files = self.hashed_files + self.reused_hashes; + if processed_files >= self.next_log_at { + log::info!( + "Processed {} media file(s) for library {} under {} ({} calculated, {} reused)", + processed_files, + self.library_name, + self.root_path, + self.hashed_files, + self.reused_hashes + ); + self.next_log_at += 100; + } + } +} + +enum DirectoryScanError { + Io(io::Error), + Handler(E), +} + +impl From for DirectoryScanError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + scan_with_rules(library, ScannerRules::for_directory(&library.kind)) +} + +pub(crate) fn scan_with_rules( + library: &MediaLibrarySettings, + rules: ScannerRules, +) -> LibraryInspection { + struct CollectingSink { + files: Vec, + } + + impl ScannerSink for CollectingSink { + type Error = Infallible; + + fn scanned_root( + &mut self, + _source_root_path: &str, + ) -> Result<(), Self::Error> { + Ok(()) + } + + fn file_hash( + &mut self, + _candidate: FileHashCandidate<'_>, + ) -> Result, Self::Error> { + Ok(None) + } + + fn file( + &mut self, + file: DiscoveredMediaFile, + ) -> Result<(), Self::Error> { + self.files.push(file); + Ok(()) + } + } + + let mut sink = CollectingSink { files: Vec::new() }; + let result = scan_with_rules_streaming(library, rules, &mut sink); + let mut inspection = match result { + Ok(inspection) => inspection, + Err(error) => match error {}, + }; + inspection.files = sink.files; + inspection +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + scan_with_rules_streaming(library, ScannerRules::for_directory(&library.kind), sink) +} + +pub(crate) fn scan_with_rules_streaming( + library: &MediaLibrarySettings, + rules: ScannerRules, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + let configured_paths = library.configured_paths(); + let path = configured_paths.first().cloned().unwrap_or_default(); + let name = display_name(library, &path); + + if configured_paths.is_empty() { + return Ok(LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: Vec::new(), + recursive: library.recursive, + kind: library.kind.clone(), + scanner: rules.scanner, + status: LibraryScanStatus::EmptyPath, + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + error: Some("Library path is empty".into()), + }, + files: Vec::new(), + scanned_root_paths: HashSet::new(), + }); + } + + let mut counters = FileCounters::default(); + let mut scanned_root_paths = HashSet::new(); + let mut errors = Vec::new(); + let mut first_failure_status = None; + + for configured_path in &configured_paths { + let filesystem_path = Path::new(configured_path); + if !filesystem_path.exists() { + first_failure_status.get_or_insert(LibraryScanStatus::MissingPath); + errors.push(format!("{}: path does not exist", configured_path)); + continue; + } + + if !filesystem_path.is_dir() { + first_failure_status.get_or_insert(LibraryScanStatus::NotDirectory); + errors.push(format!("{}: path is not a directory", configured_path)); + continue; + } + + match fs::read_dir(filesystem_path) { + Ok(entries) => drop(entries), + Err(error) => { + first_failure_status.get_or_insert(LibraryScanStatus::Unreadable); + log::warn!( + "Skipping unreadable media library root {}: {}", + configured_path, + error + ); + errors.push(format!("{}: {}", configured_path, error)); + continue; + } + } + + sink.scanned_root(configured_path)?; + log::info!( + "Scanning library {} root {} with {:?} scanner; file hashes are imohash sample values", + name, + configured_path, + rules.scanner + ); + let mut progress = ScannerProgress::new(&name, configured_path); + match scan_directory( + filesystem_path, + filesystem_path, + library.recursive, + &rules, + &mut progress, + &mut errors, + sink, + ) { + Ok(nested) => { + log::info!( + "Finished scanning library {} root {}: {} matched file(s)", + name, + configured_path, + nested.total_files + ); + scanned_root_paths.insert(configured_path.clone()); + counters.total_files += nested.total_files; + counters.video_files += nested.video_files; + counters.audio_files += nested.audio_files; + counters.image_files += nested.image_files; + counters.book_files += nested.book_files; + counters.other_files += nested.other_files; + } + Err(DirectoryScanError::Io(error)) => { + first_failure_status.get_or_insert(LibraryScanStatus::Unreadable); + errors.push(format!("{}: {}", configured_path, error)); + } + Err(DirectoryScanError::Handler(error)) => return Err(error), + } + } + + if counters.total_files > 0 || errors.len() < configured_paths.len() { + Ok(LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: configured_paths, + recursive: library.recursive, + kind: library.kind.clone(), + scanner: rules.scanner, + status: LibraryScanStatus::Available, + total_files: counters.total_files, + video_files: counters.video_files, + audio_files: counters.audio_files, + image_files: counters.image_files, + book_files: counters.book_files, + other_files: counters.other_files, + error: (!errors.is_empty()).then(|| errors.join("; ")), + }, + files: Vec::new(), + scanned_root_paths, + }) + } else { + Ok(LibraryInspection { + summary: LibraryScanSummary { + name, + path, + paths: configured_paths, + recursive: library.recursive, + kind: library.kind.clone(), + scanner: rules.scanner, + status: first_failure_status.unwrap_or(LibraryScanStatus::Unreadable), + total_files: 0, + video_files: 0, + audio_files: 0, + image_files: 0, + book_files: 0, + other_files: 0, + error: Some(errors.join("; ")), + }, + files: Vec::new(), + scanned_root_paths, + }) + } +} + +fn scan_directory( + root: &Path, + path: &Path, + recursive: bool, + rules: &ScannerRules, + progress: &mut ScannerProgress, + errors: &mut Vec, + sink: &mut S, +) -> Result> +where + S: ScannerSink, +{ + let mut counters = FileCounters::default(); + + for entry in fs::read_dir(path)? { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + record_scan_io_error(errors, path, "reading directory entry", &error); + continue; + } + }; + let entry_path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + record_scan_io_error(errors, &entry_path, "reading file type", &error); + continue; + } + }; + + if file_type.is_dir() { + if recursive { + let nested = + match scan_directory(root, &entry_path, true, rules, progress, errors, sink) { + Ok(nested) => nested, + Err(DirectoryScanError::Io(error)) => { + record_scan_io_error(errors, &entry_path, "reading directory", &error); + continue; + } + Err(DirectoryScanError::Handler(error)) => { + return Err(DirectoryScanError::Handler(error)); + } + }; + counters.total_files += nested.total_files; + counters.video_files += nested.video_files; + counters.audio_files += nested.audio_files; + counters.image_files += nested.image_files; + counters.book_files += nested.book_files; + counters.other_files += nested.other_files; + } + continue; + } + + if file_type.is_file() { + let kind = classify_file(&entry_path); + if !should_include_library_item(&entry_path, kind, &rules.include_kind) { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(error) => { + record_scan_io_error(errors, &entry_path, "reading file metadata", &error); + continue; + } + }; + let file_size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + let modified_at = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .and_then(|duration| i64::try_from(duration.as_secs()).ok()); + let source_root_path = root.to_string_lossy().to_string(); + let relative_path = normalize_relative_path(root, &entry_path); + let media_kind = kind.as_storage_value().to_string(); + let raw_default_title = entry_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_title_from_relative_path(&relative_path)); + let default_title = (rules.default_title)(&relative_path, &raw_default_title); + let file_hash = if let Some(file_hash) = sink + .file_hash(FileHashCandidate { + full_path: &entry_path, + source_root_path: &source_root_path, + relative_path: &relative_path, + file_size, + modified_at, + }) + .map_err(DirectoryScanError::Handler)? + { + progress.record_reused_hash(); + file_hash + } else { + let file_hash = match hash_file_fingerprint(&entry_path) { + Ok(file_hash) => file_hash, + Err(error) => { + record_scan_io_error(errors, &entry_path, "hashing file", &error); + continue; + } + }; + progress.record_hashed_file(); + file_hash + }; + + sink.file(DiscoveredMediaFile { + full_path: entry_path.clone(), + source_root_path, + relative_path, + file_size, + modified_at, + media_kind, + file_hash, + default_title, + }) + .map_err(DirectoryScanError::Handler)?; + + counters.total_files += 1; + match kind { + FileKind::Video => counters.video_files += 1, + FileKind::Audio => counters.audio_files += 1, + FileKind::Image => counters.image_files += 1, + FileKind::Book => counters.book_files += 1, + FileKind::Other => counters.other_files += 1, + } + } + } + + Ok(counters) +} + +fn record_scan_io_error( + errors: &mut Vec, + path: &Path, + operation: &str, + error: &io::Error, +) { + let path = path.to_string_lossy(); + log::warn!( + "Skipping {} while scanning media library: {} ({})", + path, + operation, + error + ); + errors.push(format!("{}: {}: {}", path, operation, error)); +} + +fn hash_file_fingerprint(path: &Path) -> io::Result { + let hash = ImoHasher::new().sum_file(&path.to_string_lossy())?; + let hash_hex = hash + .to_le_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(format!("imohash:{hash_hex}")) +} + +fn display_name( + library: &MediaLibrarySettings, + path: &str, +) -> String { + if !library.name.trim().is_empty() { + return library.name.trim().to_string(); + } + + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Unnamed library".into()) +} + +fn classify_file(path: &Path) -> FileKind { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + + match extension.as_deref() { + Some("mkv" | "mp4" | "avi" | "mov" | "wmv" | "m4v" | "webm" | "ts") => FileKind::Video, + Some("mp3" | "flac" | "aac" | "wav" | "ogg" | "m4a" | "opus") => FileKind::Audio, + Some("jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff") => FileKind::Image, + Some("pdf" | "epub" | "cbz" | "cbr" | "mobi") => FileKind::Book, + _ => FileKind::Other, + } +} + +fn should_include_library_item( + path: &Path, + kind: FileKind, + library_kind: &MediaLibraryKind, +) -> bool { + match library_kind { + MediaLibraryKind::Movies | MediaLibraryKind::Shows | MediaLibraryKind::HomeVideos => { + kind == FileKind::Video + } + MediaLibraryKind::Music => kind == FileKind::Audio, + MediaLibraryKind::Photos => kind == FileKind::Image, + MediaLibraryKind::Books => kind == FileKind::Book, + MediaLibraryKind::Mixed => { + matches!( + kind, + FileKind::Video | FileKind::Audio | FileKind::Image | FileKind::Book + ) && !is_named_theme_asset(path) + } + } +} + +fn is_named_theme_asset(path: &Path) -> bool { + path.file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("theme")) + .unwrap_or(false) +} + +fn normalize_relative_path( + root: &Path, + path: &Path, +) -> String { + let relative: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf(); + relative.to_string_lossy().replace('\\', "/") +} + +pub(crate) fn fallback_title_from_relative_path(relative_path: &str) -> String { + Path::new(relative_path) + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| relative_path.to_string()) +} diff --git a/crates/server/src/scanner/mod.rs b/crates/server/src/scanner/mod.rs new file mode 100644 index 00000000..02fb934a --- /dev/null +++ b/crates/server/src/scanner/mod.rs @@ -0,0 +1,154 @@ +//! Media scanner selection and file inventory primitives. + +pub mod books; +pub mod directory; +pub mod movies; +pub mod music; +pub mod photos; +pub mod shows; + +use std::collections::HashSet; +use std::path::{ + Path, + PathBuf, +}; + +use schemars::JsonSchema; +use serde::Serialize; + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; + +pub(crate) use directory::fallback_title_from_relative_path; + +/// Scan status for a configured media library. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LibraryScanStatus { + /// The library exists in configuration but has not been scanned yet. + NeverScanned, + /// The library path exists and was scanned successfully. + Available, + /// The library path was empty. + EmptyPath, + /// The library path does not exist. + MissingPath, + /// The configured path exists but is not a directory. + NotDirectory, + /// The library path could not be read completely. + Unreadable, +} + +/// Summary of one configured media library scan. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct LibraryScanSummary { + /// Human-friendly library name. + pub name: String, + /// Configured filesystem path. + pub path: String, + /// Configured filesystem paths for this logical library. + pub paths: Vec, + /// Whether the scan is recursive. + pub recursive: bool, + /// Intended media category for the library. + pub kind: MediaLibraryKind, + /// Scanner used for the library inventory. + pub scanner: MediaLibraryScanner, + /// Scan status for this library. + pub status: LibraryScanStatus, + /// Total number of files discovered. + pub total_files: u64, + /// Number of video files discovered. + pub video_files: u64, + /// Number of audio files discovered. + pub audio_files: u64, + /// Number of image files discovered. + pub image_files: u64, + /// Number of book or document files discovered. + pub book_files: u64, + /// Number of files that do not match known media extensions. + pub other_files: u64, + /// The last scan error, if any. + pub error: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LibraryInspection { + pub(crate) summary: LibraryScanSummary, + pub(crate) files: Vec, + pub(crate) scanned_root_paths: HashSet, +} + +#[derive(Debug, Clone)] +pub(crate) struct DiscoveredMediaFile { + pub(crate) full_path: PathBuf, + pub(crate) source_root_path: String, + pub(crate) relative_path: String, + pub(crate) file_size: i64, + pub(crate) modified_at: Option, + pub(crate) media_kind: String, + pub(crate) file_hash: String, + pub(crate) default_title: String, +} + +pub(crate) struct FileHashCandidate<'a> { + pub(crate) full_path: &'a Path, + pub(crate) source_root_path: &'a str, + pub(crate) relative_path: &'a str, + pub(crate) file_size: i64, + pub(crate) modified_at: Option, +} + +pub(crate) trait ScannerSink { + type Error; + + fn scanned_root( + &mut self, + source_root_path: &str, + ) -> Result<(), Self::Error>; + + fn file_hash( + &mut self, + candidate: FileHashCandidate<'_>, + ) -> Result, Self::Error>; + + fn file( + &mut self, + file: DiscoveredMediaFile, + ) -> Result<(), Self::Error>; +} + +/// Inspect a configured library with its selected scanner. +pub(crate) fn inspect_library(library: &MediaLibrarySettings) -> LibraryInspection { + match library.scanner.effective_for_kind(&library.kind) { + MediaLibraryScanner::Auto => directory::scan(library), + MediaLibraryScanner::Directory => directory::scan(library), + MediaLibraryScanner::Movies => movies::scan(library), + MediaLibraryScanner::Shows => shows::scan(library), + MediaLibraryScanner::Music => music::scan(library), + MediaLibraryScanner::Photos => photos::scan(library), + MediaLibraryScanner::Books => books::scan(library), + } +} + +/// Inspect a configured library and stream each discovered media file to the caller. +pub(crate) fn inspect_library_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + match library.scanner.effective_for_kind(&library.kind) { + MediaLibraryScanner::Auto => directory::scan_streaming(library, sink), + MediaLibraryScanner::Directory => directory::scan_streaming(library, sink), + MediaLibraryScanner::Movies => movies::scan_streaming(library, sink), + MediaLibraryScanner::Shows => shows::scan_streaming(library, sink), + MediaLibraryScanner::Music => music::scan_streaming(library, sink), + MediaLibraryScanner::Photos => photos::scan_streaming(library, sink), + MediaLibraryScanner::Books => books::scan_streaming(library, sink), + } +} diff --git a/crates/server/src/scanner/movies.rs b/crates/server/src/scanner/movies.rs new file mode 100644 index 00000000..c6dcbe79 --- /dev/null +++ b/crates/server/src/scanner/movies.rs @@ -0,0 +1,101 @@ +//! Movie scanner rules. + +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::directory::{ + self, + ScannerRules, +}; +use crate::scanner::{ + LibraryInspection, + ScannerSink, +}; + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + directory::scan_with_rules( + library, + ScannerRules::typed( + MediaLibraryScanner::Movies, + MediaLibraryKind::Movies, + |_, title| display_title_from_name(title), + ), + ) +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + directory::scan_with_rules_streaming( + library, + ScannerRules::typed( + MediaLibraryScanner::Movies, + MediaLibraryKind::Movies, + |_, title| display_title_from_name(title), + ), + sink, + ) +} + +fn display_title_from_name(value: &str) -> String { + static BRACKETED_TAG_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\{\[]([^\}\]]*)[\}\]]").unwrap()); + static YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\b(19\d{2}|20\d{2}|21\d{2})\b").unwrap()); + static PARENTHETICAL_YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\(\[]\s*(19\d{2}|20\d{2}|21\d{2})\s*[\)\]]").unwrap()); + static DASH_FORMAT_SUFFIX_REGEX: Lazy = Lazy::new(|| { + Regex::new(concat!( + r"(?i)\s+[-–]\s+(?:bluray|blu-ray|brrip|web[- ]?dl|webrip|remux", + r"|dvdrip|hdtv|uhd|dvd|proper|repack|extended|unrated|director'?s cut", + r"|theatrical|final cut)?(?:[\s._-]*(?:2160p|1080p|720p|480p|4k", + r"|uhd|hdr|dv|x264|x265|h264|h265|hevc|av1|aac|dts|truehd|atmos", + r"|remux|bluray|blu-ray|web[- ]?dl|webrip|brrip|dvdrip))*\s*$", + )) + .unwrap() + }); + static NOISE_TOKEN_REGEX: Lazy = Lazy::new(|| { + Regex::new(concat!( + r"(?i)\b(2160p|1080p|720p|480p|4k|uhd|x264|x265|h264|h265", + r"|hevc|av1|hdr|dv|webrip|web[- ]?dl|bluray|blu-ray|brrip", + r"|dvdrip|remux|aac|dts|truehd|atmos)\b", + )) + .unwrap() + }); + static TITLE_COLON_DASH_REGEX: Lazy = Lazy::new(|| Regex::new(r"\s*-\s+").unwrap()); + + let without_tags = BRACKETED_TAG_REGEX.replace_all(value, " "); + let mut normalized = DASH_FORMAT_SUFFIX_REGEX + .replace(&without_tags, " ") + .to_string(); + normalized = PARENTHETICAL_YEAR_REGEX + .replace(&normalized, " ") + .to_string(); + normalized = normalized.replace(['.', '_'], " "); + if let Some(year_match) = YEAR_REGEX.find(&normalized) { + if !normalized[..year_match.start()].trim().is_empty() { + normalized = normalized[..year_match.start()].to_string(); + } + } + normalized = TITLE_COLON_DASH_REGEX + .replace_all(&normalized, ": ") + .to_string(); + normalized = NOISE_TOKEN_REGEX.replace_all(&normalized, " ").to_string(); + let cleaned = normalized + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_string(); + + if cleaned.is_empty() { value.to_string() } else { cleaned } +} diff --git a/crates/server/src/scanner/music.rs b/crates/server/src/scanner/music.rs new file mode 100644 index 00000000..8906280b --- /dev/null +++ b/crates/server/src/scanner/music.rs @@ -0,0 +1,44 @@ +//! Music scanner rules. + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::directory::{ + self, + ScannerRules, +}; +use crate::scanner::{ + LibraryInspection, + ScannerSink, +}; + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + directory::scan_with_rules( + library, + ScannerRules::typed( + MediaLibraryScanner::Music, + MediaLibraryKind::Music, + |_, title| title.to_string(), + ), + ) +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + directory::scan_with_rules_streaming( + library, + ScannerRules::typed( + MediaLibraryScanner::Music, + MediaLibraryKind::Music, + |_, title| title.to_string(), + ), + sink, + ) +} diff --git a/crates/server/src/scanner/photos.rs b/crates/server/src/scanner/photos.rs new file mode 100644 index 00000000..16a74df8 --- /dev/null +++ b/crates/server/src/scanner/photos.rs @@ -0,0 +1,44 @@ +//! Photo scanner rules. + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::directory::{ + self, + ScannerRules, +}; +use crate::scanner::{ + LibraryInspection, + ScannerSink, +}; + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + directory::scan_with_rules( + library, + ScannerRules::typed( + MediaLibraryScanner::Photos, + MediaLibraryKind::Photos, + |_, title| title.to_string(), + ), + ) +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + directory::scan_with_rules_streaming( + library, + ScannerRules::typed( + MediaLibraryScanner::Photos, + MediaLibraryKind::Photos, + |_, title| title.to_string(), + ), + sink, + ) +} diff --git a/crates/server/src/scanner/shows.rs b/crates/server/src/scanner/shows.rs new file mode 100644 index 00000000..a3ae9267 --- /dev/null +++ b/crates/server/src/scanner/shows.rs @@ -0,0 +1,265 @@ +//! TV show scanner rules. + +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::config::{ + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, +}; +use crate::scanner::directory::{ + self, + ScannerRules, +}; +use crate::scanner::{ + LibraryInspection, + ScannerSink, +}; + +/// Show, season, and episode fields derived from a library-relative episode path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ParsedShowPath { + pub(crate) show_title: String, + pub(crate) show_key: String, + pub(crate) season_title: String, + pub(crate) season_key: String, + pub(crate) season_number: Option, + pub(crate) episode_title: String, + pub(crate) episode_key: String, + pub(crate) episode_number: Option, +} + +pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { + directory::scan_with_rules( + library, + ScannerRules::typed( + MediaLibraryScanner::Shows, + MediaLibraryKind::Shows, + |relative_path, title| parse_show_path(relative_path, title, 0).episode_title, + ), + ) +} + +pub(crate) fn scan_streaming( + library: &MediaLibrarySettings, + sink: &mut S, +) -> Result +where + S: ScannerSink, +{ + directory::scan_with_rules_streaming( + library, + ScannerRules::typed( + MediaLibraryScanner::Shows, + MediaLibraryKind::Shows, + |relative_path, title| parse_show_path(relative_path, title, 0).episode_title, + ), + sink, + ) +} + +/// Parse a show episode path following the documented Koko show naming forms. +pub(crate) fn parse_show_path( + relative_path: &str, + fallback_title: &str, + library_id: i32, +) -> ParsedShowPath { + let normalized = relative_path.replace('\\', "/"); + let parts = normalized + .split('/') + .filter(|part| !part.trim().is_empty()) + .collect::>(); + let filename = parts.last().copied().unwrap_or(fallback_title); + let file_stem = filename + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(filename); + let raw_show_title = parts + .first() + .copied() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(fallback_title); + let show_title = clean_show_title(raw_show_title) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_title.trim().to_string()); + let season_source = + if parts.len() >= 2 { parts[parts.len().saturating_sub(2)] } else { fallback_title }; + let season_number = infer_season_number(season_source) + .or_else(|| infer_season_number(file_stem)) + .or_else(|| infer_season_number(fallback_title)) + .filter(|value| *value > 0); + let episode_number = infer_episode_number(file_stem) + .or_else(|| infer_episode_number(fallback_title)) + .filter(|value| *value > 0); + let episode_title = episode_title_from_name(file_stem, &show_title, episode_number) + .or_else(|| episode_title_from_name(fallback_title, &show_title, episode_number)) + .unwrap_or_else(|| cleaned_episode_fallback(fallback_title)); + let season_title = season_number + .map(|number| format!("Season {}", number)) + .unwrap_or_else(|| season_source.trim().to_string()); + let show_key = format!( + "library:{}:show:{}", + library_id, + normalize_identity_segment(&show_title) + ); + let season_key = format!( + "{}:season:{}", + show_key, + season_number + .map(|value| value.to_string()) + .unwrap_or_else(|| normalize_identity_segment(&season_title)) + ); + let episode_key = format!( + "{}:episode:{}:{}", + season_key, + episode_number + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".into()), + normalize_identity_segment(&episode_title) + ); + + ParsedShowPath { + show_title, + show_key, + season_title, + season_key, + season_number, + episode_title, + episode_key, + episode_number, + } +} + +/// Infer a season number from common folder or filename patterns. +pub fn infer_season_number(value: &str) -> Option { + static SEASON_PATTERNS: Lazy> = Lazy::new(|| { + vec![ + Regex::new(r"(?i)(?:^|[^a-z0-9])season\s*(\d{1,3})(?:[^0-9]|$)").unwrap(), + Regex::new(r"(?i)(?:^|[^a-z0-9])series\s*(\d{1,3})(?:[^0-9]|$)").unwrap(), + Regex::new(r"(?i)(?:^|[^a-z0-9])s(\d{1,3})(?:\s*e\d{1,3}|[^0-9]|$)").unwrap(), + Regex::new(r"(?i)(?:^|[^a-z0-9])(\d{1,3})x\d{1,3}(?:[^0-9]|$)").unwrap(), + ] + }); + + first_pattern_number(value, &SEASON_PATTERNS) +} + +/// Infer an episode number from common filename patterns such as `S03E01` or `3x01`. +pub fn infer_episode_number(value: &str) -> Option { + static EPISODE_PATTERNS: Lazy> = Lazy::new(|| { + vec![ + Regex::new(r"(?i)(?:^|[^a-z0-9])s\d{1,3}\s*e(\d{1,3})(?:[^0-9]|$)").unwrap(), + Regex::new(r"(?i)(?:^|[^a-z0-9])\d{1,3}x(\d{1,3})(?:[^0-9]|$)").unwrap(), + Regex::new(r"(?i)(?:^|[^a-z0-9])e(\d{1,3})(?:[^0-9]|$)").unwrap(), + ] + }); + + first_pattern_number(value, &EPISODE_PATTERNS) +} + +fn first_pattern_number( + value: &str, + patterns: &[Regex], +) -> Option { + patterns.iter().find_map(|pattern| { + pattern + .captures(value) + .and_then(|captures| captures.get(1)) + .and_then(|matched| matched.as_str().parse::().ok()) + }) +} + +fn clean_show_title(value: &str) -> Option { + static BRACED_TAG_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\{\[]([^\}\]]*)[\}\]]").unwrap()); + static PARENTHETICAL_YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\(\[]\s*(19\d{2}|20\d{2}|21\d{2})\s*[\)\]]").unwrap()); + static YEAR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"\b(19\d{2}|20\d{2}|21\d{2})\b").unwrap()); + + let without_tags = BRACED_TAG_REGEX.replace_all(value, " "); + let mut normalized = PARENTHETICAL_YEAR_REGEX + .replace(&without_tags, " ") + .replace(['.', '_'], " "); + if let Some(year_match) = YEAR_REGEX.find(&normalized) { + if !normalized[..year_match.start()].trim().is_empty() { + normalized = normalized[..year_match.start()].to_string(); + } + } + cleaned_text(&normalized) +} + +fn episode_title_from_name( + value: &str, + show_title: &str, + episode_number: Option, +) -> Option { + static EPISODE_MARKER_WITH_TITLE_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"(?i)(?:s\d{1,3}\s*e\d{1,3}|\d{1,3}x\d{1,3}|e\d{1,3})\s*(?:[-–:._ ]+)\s*(.+)$") + .unwrap() + }); + + let normalized = value.replace(['.', '_'], " "); + if let Some(candidate) = EPISODE_MARKER_WITH_TITLE_REGEX + .captures(&normalized) + .and_then(|captures| captures.get(1)) + .and_then(|matched| cleaned_text(matched.as_str())) + { + return Some(candidate); + } + + let mut cleaned = normalized; + if !show_title.trim().is_empty() + && cleaned + .to_ascii_lowercase() + .starts_with(&show_title.to_ascii_lowercase()) + { + cleaned = cleaned[show_title.len()..].to_string(); + } + if let Some(number) = episode_number { + for marker in [ + format!( + "S{:02}E{:02}", + infer_season_number(value).unwrap_or_default(), + number + ), + format!("E{:02}", number), + format!("x{:02}", number), + ] { + cleaned = cleaned.replace(&marker, " "); + cleaned = cleaned.replace(&marker.to_ascii_lowercase(), " "); + } + } + + cleaned_text(&cleaned) +} + +fn cleaned_episode_fallback(value: &str) -> String { + cleaned_text(&value.replace(['.', '_'], " ")).unwrap_or_else(|| value.trim().to_string()) +} + +fn cleaned_text(value: &str) -> Option { + let collapsed = value + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_string(); + (!collapsed.trim().is_empty()).then_some(collapsed) +} + +fn normalize_identity_segment(value: &str) -> String { + value + .chars() + .map( + |character| { + if character.is_ascii_alphanumeric() { character.to_ascii_lowercase() } else { '-' } + }, + ) + .collect::() + .split('-') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-") +} diff --git a/crates/server/src/scheduled_tasks/database_maintenance.rs b/crates/server/src/scheduled_tasks/database_maintenance.rs new file mode 100644 index 00000000..d529bd42 --- /dev/null +++ b/crates/server/src/scheduled_tasks/database_maintenance.rs @@ -0,0 +1,73 @@ +//! Scheduled database maintenance task. + +// lib imports +use diesel::connection::SimpleConnection; + +// local imports +use crate::config::ScheduledTasksSettings; +use crate::db::DbConn; +use crate::utils::current_timestamp; + +use super::{ + ScheduledTask, + ScheduledTaskFuture, + save_scheduled_task_last_run, +}; + +const LAST_RUN_KEY: &str = "scheduled_tasks.database_maintenance.last_run_at"; + +static TASK: DatabaseMaintenanceTask = DatabaseMaintenanceTask; + +pub(super) fn task() -> &'static dyn ScheduledTask { + &TASK +} + +struct DatabaseMaintenanceTask; + +impl ScheduledTask for DatabaseMaintenanceTask { + fn id(&self) -> &'static str { + "database_maintenance" + } + + fn name(&self) -> &'static str { + "database maintenance" + } + + fn enabled( + &self, + settings: &ScheduledTasksSettings, + ) -> bool { + settings.database_maintenance.enabled + } + + fn interval_days( + &self, + settings: &ScheduledTasksSettings, + ) -> Option { + Some(settings.database_maintenance.interval_days) + } + + fn last_run_key(&self) -> Option<&'static str> { + Some(LAST_RUN_KEY) + } + + fn run_now<'a>( + &'a self, + db: &'a DbConn, + _settings: &'a ScheduledTasksSettings, + ) -> ScheduledTaskFuture<'a> { + Box::pin(async move { + log::info!("Starting scheduled database maintenance"); + db.run(move |conn| { + conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE); VACUUM; PRAGMA optimize;")?; + save_scheduled_task_last_run(conn, LAST_RUN_KEY, current_timestamp())?; + Ok::<(), diesel::result::Error>(()) + }) + .await + .map_err(|error| error.to_string())?; + + log::info!("Scheduled database maintenance completed"); + Ok(()) + }) + } +} diff --git a/crates/server/src/scheduled_tasks/metadata_refresh.rs b/crates/server/src/scheduled_tasks/metadata_refresh.rs new file mode 100644 index 00000000..a5270605 --- /dev/null +++ b/crates/server/src/scheduled_tasks/metadata_refresh.rs @@ -0,0 +1,50 @@ +//! Scheduled metadata refresh task. + +// local imports +use crate::config::{ + ScheduledTasksSettings, + current_settings, +}; +use crate::db::DbConn; + +use super::{ + ScheduledTask, + ScheduledTaskFuture, +}; + +static TASK: MetadataRefreshTask = MetadataRefreshTask; + +pub(super) fn task() -> &'static dyn ScheduledTask { + &TASK +} + +struct MetadataRefreshTask; + +impl ScheduledTask for MetadataRefreshTask { + fn id(&self) -> &'static str { + "metadata_refresh" + } + + fn name(&self) -> &'static str { + "metadata refresh" + } + + fn enabled( + &self, + settings: &ScheduledTasksSettings, + ) -> bool { + settings.metadata_refresh.enabled + } + + fn run_now<'a>( + &'a self, + db: &'a DbConn, + _settings: &'a ScheduledTasksSettings, + ) -> ScheduledTaskFuture<'a> { + Box::pin(async move { + let settings = current_settings(); + crate::web::routes::media::run_scheduled_metadata_refreshes(db, &settings).await; + Ok(()) + }) + } +} diff --git a/crates/server/src/scheduled_tasks/mod.rs b/crates/server/src/scheduled_tasks/mod.rs new file mode 100644 index 00000000..ee0be1ed --- /dev/null +++ b/crates/server/src/scheduled_tasks/mod.rs @@ -0,0 +1,291 @@ +//! Scheduled background task runner. + +// modules +mod database_maintenance; +mod metadata_refresh; +mod trash_cleanup; + +// lib imports +use chrono::{ + Datelike, + Local, + Timelike, +}; +use diesel::ExpressionMethods; +use diesel::OptionalExtension; +use diesel::QueryDsl; +use diesel::RunQueryDsl; +use diesel::SelectableHelper; +use once_cell::sync::Lazy; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{ + AtomicBool, + Ordering, +}; + +// local imports +use crate::config::{ + ScheduledTaskWeekday, + ScheduledTasksSettings, + current_settings, +}; +use crate::db::DbConn; +use crate::db::models::AppSetting; + +const SCHEDULER_INTERVAL_SECONDS: u64 = 60; + +static SCHEDULED_TASK_RUNNER_RUNNING: Lazy = Lazy::new(|| AtomicBool::new(false)); + +/// Future returned by scheduled task implementations. +pub(super) type ScheduledTaskFuture<'a> = + Pin> + Send + 'a>>; + +/// Common behavior for a scheduled task. +pub(super) trait ScheduledTask: Sync { + /// Stable task identifier used by settings and manual-run routes. + fn id(&self) -> &'static str; + /// Human-readable task name used in logs. + fn name(&self) -> &'static str; + /// Whether the task is enabled in the current scheduled task settings. + fn enabled( + &self, + settings: &ScheduledTasksSettings, + ) -> bool; + /// Minimum number of days between runs, when the task uses scheduler-level last-run state. + fn interval_days( + &self, + _settings: &ScheduledTasksSettings, + ) -> Option { + None + } + /// App setting key used to store scheduler-level last-run state. + fn last_run_key(&self) -> Option<&'static str> { + None + } + /// Run the task immediately. + fn run_now<'a>( + &'a self, + db: &'a DbConn, + settings: &'a ScheduledTasksSettings, + ) -> ScheduledTaskFuture<'a>; + /// Run the task from the scheduler, respecting optional last-run state. + fn run_scheduled<'a>( + &'a self, + db: &'a DbConn, + settings: &'a ScheduledTasksSettings, + ) -> ScheduledTaskFuture<'a> { + Box::pin(async move { + if let (Some(last_run_key), Some(interval_days)) = + (self.last_run_key(), self.interval_days(settings)) + { + let should_run = + scheduled_task_interval_is_due(db, last_run_key, interval_days).await?; + if !should_run { + return Ok(()); + } + } + + self.run_now(db, settings).await + }) + } + /// Run the task from a manual request, ignoring the scheduled window. + fn run_manual<'a>( + &'a self, + db: &'a DbConn, + ) -> ScheduledTaskFuture<'a> { + Box::pin(async move { + let settings = current_settings(); + self.run_now(db, &settings.scheduled_tasks).await + }) + } +} + +/// Start the scheduled background task runner. +pub fn start_scheduled_task_runner(db: DbConn) { + if SCHEDULED_TASK_RUNNER_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + rocket::tokio::spawn(async move { + loop { + let settings = current_settings(); + if scheduled_tasks_can_start_now(&settings.scheduled_tasks) { + for task in scheduled_tasks() { + if !task.enabled(&settings.scheduled_tasks) { + continue; + } + + if let Err(error) = task.run_scheduled(&db, &settings.scheduled_tasks).await { + log::error!("Scheduled {} failed: {}", task.name(), error); + } + } + } + + rocket::tokio::time::sleep(rocket::tokio::time::Duration::from_secs( + SCHEDULER_INTERVAL_SECONDS, + )) + .await; + } + }); +} + +/// Start a metadata refresh task immediately, outside the scheduled window. +pub fn start_metadata_refresh_task(db: DbConn) { + start_task(metadata_refresh::task(), db); +} + +/// Start a trash cleanup task immediately, outside the scheduled window. +pub fn start_trash_cleanup_task(db: DbConn) { + start_task(trash_cleanup::task(), db); +} + +/// Start database maintenance immediately, outside the scheduled window. +pub fn start_database_maintenance_task(db: DbConn) { + start_task(database_maintenance::task(), db); +} + +fn scheduled_tasks() -> [&'static dyn ScheduledTask; 3] { + [ + metadata_refresh::task(), + trash_cleanup::task(), + database_maintenance::task(), + ] +} + +fn start_task( + task: &'static dyn ScheduledTask, + db: DbConn, +) { + rocket::tokio::spawn(async move { + log::info!( + "Starting manual scheduled task {} ({})", + task.name(), + task.id() + ); + if let Err(error) = task.run_manual(&db).await { + log::error!("Manual {} failed: {}", task.name(), error); + } + }); +} + +fn scheduled_tasks_can_start_now(settings: &ScheduledTasksSettings) -> bool { + if !settings.enabled { + return false; + } + + let now = Local::now(); + let weekday = scheduled_weekday_from_chrono(now.weekday()); + if !settings.window.weekdays.iter().any(|day| day == &weekday) { + return false; + } + + let Some(start) = parse_minutes_since_midnight(&settings.window.start_time) else { + return false; + }; + let Some(stop) = parse_minutes_since_midnight(&settings.window.stop_time) else { + return false; + }; + let current = now.hour() * 60 + now.minute(); + + if start == stop { + return true; + } + if start < stop { + current >= start && current < stop + } else { + current >= start || current < stop + } +} + +fn scheduled_weekday_from_chrono(weekday: chrono::Weekday) -> ScheduledTaskWeekday { + match weekday { + chrono::Weekday::Mon => ScheduledTaskWeekday::Monday, + chrono::Weekday::Tue => ScheduledTaskWeekday::Tuesday, + chrono::Weekday::Wed => ScheduledTaskWeekday::Wednesday, + chrono::Weekday::Thu => ScheduledTaskWeekday::Thursday, + chrono::Weekday::Fri => ScheduledTaskWeekday::Friday, + chrono::Weekday::Sat => ScheduledTaskWeekday::Saturday, + chrono::Weekday::Sun => ScheduledTaskWeekday::Sunday, + } +} + +fn parse_minutes_since_midnight(value: &str) -> Option { + let (hour, minute) = value.trim().split_once(':')?; + let hour = hour.parse::().ok()?; + let minute = minute.parse::().ok()?; + (hour < 24 && minute < 60).then_some(hour * 60 + minute) +} + +async fn scheduled_task_interval_is_due( + db: &DbConn, + last_run_key: &'static str, + interval_days: u32, +) -> Result { + let interval_seconds = i64::from(interval_days).saturating_mul(24 * 60 * 60); + let now = crate::utils::current_timestamp(); + + db.run(move |conn| { + let last_run = load_scheduled_task_last_run(conn, last_run_key)?; + Ok::( + last_run + .map(|last_run| now.saturating_sub(last_run) >= interval_seconds) + .unwrap_or(true), + ) + }) + .await + .map_err(|error| error.to_string()) +} + +pub(super) fn load_scheduled_task_last_run( + conn: &mut rocket_sync_db_pools::diesel::SqliteConnection, + setting_key: &str, +) -> Result, diesel::result::Error> { + use crate::db::schema::app_settings::dsl as app_settings_dsl; + + app_settings_dsl::app_settings + .filter(app_settings_dsl::key.eq(setting_key)) + .select(AppSetting::as_select()) + .first::(conn) + .optional() + .map(|row| row.and_then(|row| row.value.parse::().ok())) +} + +pub(super) fn save_scheduled_task_last_run( + conn: &mut rocket_sync_db_pools::diesel::SqliteConnection, + setting_key: &str, + timestamp: i64, +) -> Result<(), diesel::result::Error> { + use crate::db::schema::app_settings::dsl as app_settings_dsl; + + diesel::insert_into(app_settings_dsl::app_settings) + .values(AppSetting { + key: setting_key.to_string(), + value: timestamp.to_string(), + updated_at: Some(timestamp), + }) + .on_conflict(app_settings_dsl::key) + .do_update() + .set(( + app_settings_dsl::value.eq(diesel::upsert::excluded(app_settings_dsl::value)), + app_settings_dsl::updated_at.eq(diesel::upsert::excluded(app_settings_dsl::updated_at)), + )) + .execute(conn) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::parse_minutes_since_midnight; + + #[test] + fn parse_minutes_rejects_invalid_time_values() { + assert_eq!(parse_minutes_since_midnight("02:30"), Some(150)); + assert_eq!(parse_minutes_since_midnight("24:00"), None); + assert_eq!(parse_minutes_since_midnight("02:60"), None); + assert_eq!(parse_minutes_since_midnight("nope"), None); + } +} diff --git a/crates/server/src/scheduled_tasks/trash_cleanup.rs b/crates/server/src/scheduled_tasks/trash_cleanup.rs new file mode 100644 index 00000000..1d75326c --- /dev/null +++ b/crates/server/src/scheduled_tasks/trash_cleanup.rs @@ -0,0 +1,88 @@ +//! Scheduled trash cleanup task. + +// local imports +use crate::config::ScheduledTasksSettings; +use crate::db::DbConn; +use crate::media::delete_missing_media_items; +use crate::utils::current_timestamp; + +use super::{ + ScheduledTask, + ScheduledTaskFuture, + save_scheduled_task_last_run, +}; + +const LAST_RUN_KEY: &str = "scheduled_tasks.trash_cleanup.last_run_at"; + +static TASK: TrashCleanupTask = TrashCleanupTask; + +pub(super) fn task() -> &'static dyn ScheduledTask { + &TASK +} + +struct TrashCleanupTask; + +impl ScheduledTask for TrashCleanupTask { + fn id(&self) -> &'static str { + "trash_cleanup" + } + + fn name(&self) -> &'static str { + "trash cleanup" + } + + fn enabled( + &self, + settings: &ScheduledTasksSettings, + ) -> bool { + settings.trash_cleanup.enabled + } + + fn interval_days( + &self, + settings: &ScheduledTasksSettings, + ) -> Option { + Some(settings.trash_cleanup.interval_days) + } + + fn last_run_key(&self) -> Option<&'static str> { + Some(LAST_RUN_KEY) + } + + fn run_now<'a>( + &'a self, + db: &'a DbConn, + settings: &'a ScheduledTasksSettings, + ) -> ScheduledTaskFuture<'a> { + Box::pin(async move { + let Some(days) = settings + .trash_cleanup + .missing_item_auto_delete_days + .filter(|days| *days > 0) + else { + return Ok(()); + }; + + log::info!("Starting scheduled trash cleanup"); + db.run(move |conn| { + let cutoff = current_timestamp().saturating_sub(i64::from(days) * 24 * 60 * 60); + let summary = delete_missing_media_items(conn, None, Some(cutoff))?; + if summary.deleted_items > 0 || summary.deleted_files > 0 { + log::info!( + "Scheduled trash cleanup deleted {} missing item rows and {} missing file \ + rows", + summary.deleted_items, + summary.deleted_files + ); + } + save_scheduled_task_last_run(conn, LAST_RUN_KEY, current_timestamp())?; + Ok::<(), diesel::result::Error>(()) + }) + .await + .map_err(|error| error.to_string())?; + + log::info!("Scheduled trash cleanup completed"); + Ok(()) + }) + } +} diff --git a/crates/server/src/secrets.rs b/crates/server/src/secrets.rs new file mode 100644 index 00000000..a9029261 --- /dev/null +++ b/crates/server/src/secrets.rs @@ -0,0 +1,115 @@ +//! Secret-store helpers for sensitive runtime settings. + +// standard imports +use std::collections::HashMap; +use std::sync::Mutex; + +// lib imports +use keyring_core::{ + Entry, + Error, + set_default_store, +}; +use once_cell::sync::Lazy; + +// local imports +use crate::globals::GLOBAL_APP_NAME; + +static SECRET_STORE_INIT: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +fn initialize_secret_store() -> Result<(), String> { + let configured_store = std::env::var("KOKO_SECRET_STORE") + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + match configured_store.as_str() { + "" | "native" | "os" => use_native_secret_store(), + "memory" | "mock" | "sample" => { + let config = HashMap::from([("persist", "false")]); + use_sample_secret_store(&config) + } + store => use_named_secret_store(store), + } + .map_err(|error| format!("Failed to initialize credential store: {error}")) +} + +fn use_sample_secret_store(config: &HashMap<&str, &str>) -> keyring_core::Result<()> { + set_default_store(keyring_core::sample::Store::new_with_configuration(config)?); + Ok(()) +} + +#[cfg(feature = "native-secret-store")] +fn use_named_secret_store(store: &str) -> keyring_core::Result<()> { + keyring::use_named_store(store) +} + +#[cfg(not(feature = "native-secret-store"))] +fn use_named_secret_store(store: &str) -> keyring_core::Result<()> { + Err(Error::NotSupportedByStore(format!( + "credential store {store:?} is not available in this build" + ))) +} + +#[cfg(feature = "native-secret-store")] +fn use_native_secret_store() -> keyring_core::Result<()> { + #[cfg(target_os = "linux")] + { + keyring::use_native_store(true) + } + #[cfg(not(target_os = "linux"))] + { + keyring::use_native_store(false) + } +} + +#[cfg(not(feature = "native-secret-store"))] +fn use_native_secret_store() -> keyring_core::Result<()> { + let config = HashMap::from([("persist", "false")]); + use_sample_secret_store(&config) +} + +fn ensure_secret_store() -> Result<(), String> { + let mut guard = SECRET_STORE_INIT + .lock() + .map_err(|_| "Secret-store initialization lock is poisoned.".to_string())?; + if let Some(result) = guard.as_ref() { + return result.clone(); + } + + let result = initialize_secret_store(); + *guard = Some(result.clone()); + result +} + +fn secret_entry(secret_ref: &str) -> Result { + ensure_secret_store()?; + Entry::new(GLOBAL_APP_NAME, secret_ref) + .map_err(|error| format!("Failed to open credential entry {secret_ref:?}: {error}")) +} + +pub(crate) fn store_secret( + secret_ref: &str, + value: &str, +) -> Result<(), String> { + secret_entry(secret_ref)? + .set_password(value) + .map_err(|error| format!("Failed to store credential {secret_ref:?}: {error}")) +} + +pub(crate) fn load_secret(secret_ref: &str) -> Result, String> { + match secret_entry(secret_ref)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(Error::NoEntry) => Ok(None), + Err(error) => Err(format!("Failed to load credential {secret_ref:?}: {error}")), + } +} + +pub(crate) fn delete_secret(secret_ref: &str) -> Result<(), String> { + match secret_entry(secret_ref)?.delete_credential() { + Ok(()) | Err(Error::NoEntry) => Ok(()), + Err(error) => Err(format!( + "Failed to delete credential {secret_ref:?}: {error}" + )), + } +} diff --git a/crates/server/src/signal_handler.rs b/crates/server/src/signal_handler.rs index 62419e47..89dc5f95 100644 --- a/crates/server/src/signal_handler.rs +++ b/crates/server/src/signal_handler.rs @@ -1,7 +1,10 @@ //! Signal handling utilities for graceful shutdown. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{ + AtomicBool, + Ordering, +}; use std::thread::JoinHandle; use std::time::Duration; diff --git a/crates/server/src/transcode.rs b/crates/server/src/transcode.rs new file mode 100644 index 00000000..8f6f711f --- /dev/null +++ b/crates/server/src/transcode.rs @@ -0,0 +1,237 @@ +//! Transcoding engine for media playback. + +// standard imports +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{ + AtomicU64, + Ordering, +}; + +// lib imports +use tokio::fs; +use tokio::process::Child; +use tokio::process::Command; + +// local imports +use crate::config::FfmpegSettings; + +static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Create a new unique session ID. +pub fn next_session_id() -> String { + format!("session-{}", NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst)) +} + +/// Details for an active FFmpeg transcoding process. +pub struct TranscodeProcess { + /// The unique session ID for this process. + pub session_id: String, + /// The path to the output directory or file. + pub output_path: PathBuf, +} + +impl TranscodeProcess { + /// Stop the transcode process and clean up temporary files. + pub async fn cleanup(&self) { + if let Some(parent) = self.output_path.parent() { + let _ = fs::remove_dir_all(parent).await; + } + } +} + +/// Specification for a transcode job. +#[derive(Debug, Clone)] +pub struct TranscodeSpec { + /// The path to the source media file. + pub source_path: PathBuf, + /// The output path where the transcoded media will be written. + pub output_path: PathBuf, + /// The container format to use. + pub container: String, + /// The video codec to use, or None to copy. + pub video_codec: Option, + /// The audio codec to use, or None to copy. + pub audio_codec: Option, + /// The maximum width of the video. + pub max_width: Option, + /// The maximum height of the video. + pub max_height: Option, + /// The maximum total bitrate in kbps. + pub max_bitrate_kbps: Option, + /// The start time in milliseconds to seek to. + pub start_time_ms: Option, + /// Zero-based audio stream index among audio streams. + pub audio_stream_index: Option, +} + +impl TranscodeSpec { + /// Generate the FFmpeg command-line arguments for this specification. + pub fn to_ffmpeg_args(&self) -> Vec { + self.to_ffmpeg_args_for_output(self.output_path.to_string_lossy().as_ref()) + } + + /// Generate FFmpeg command-line arguments using stdout as the output target. + pub fn to_ffmpeg_stdout_args(&self) -> Vec { + self.to_ffmpeg_args_for_output("pipe:1") + } + + fn to_ffmpeg_args_for_output( + &self, + output_target: &str, + ) -> Vec { + // Avoid writing banner and stats + let mut args = vec![ + "-hide_banner".into(), + "-loglevel".into(), + "warning".into(), + "-fflags".into(), + "+genpts".into(), + ]; + + if let Some(start_time) = self.start_time_ms { + let start_sec = start_time as f64 / 1000.0; + args.push("-ss".into()); + args.push(format!("{:.3}", start_sec)); + } + + // Input + args.push("-i".into()); + args.push(self.source_path.to_string_lossy().into_owned()); + + // Copy all streams by default if we don't map explicitly, but for transcode we usually map + args.push("-map".into()); + args.push("0:v:0?".into()); // First video + args.push("-map".into()); + args.push(format!("0:a:{}?", self.audio_stream_index.unwrap_or(0))); + + // Video codec + args.push("-c:v".into()); + if let Some(vc) = &self.video_codec { + args.push(vc.clone()); + if vc == "libx264" { + args.push("-preset".into()); + args.push("veryfast".into()); + args.push("-pix_fmt".into()); + args.push("yuv420p".into()); + } + + // Add scale filter if we need resizing + if self.max_width.unwrap_or(0) > 0 || self.max_height.unwrap_or(0) > 0 { + let w = self.max_width.unwrap_or(u32::MAX); + let h = self.max_height.unwrap_or(u32::MAX); + // Simple scale filter that preserves aspect ratio and doesn't up-scale + args.push("-vf".into()); + args.push(format!( + "scale=w='min({w}\\,iw)':h='min({h}\\,ih)':\ + force_original_aspect_ratio=decrease" + )); + } + } else { + args.push("copy".into()); + } + + // Audio codec + args.push("-c:a".into()); + if let Some(ac) = &self.audio_codec { + args.push(ac.clone()); + if ac == "aac" { + args.push("-ac".into()); + args.push("2".into()); + args.push("-b:a".into()); + args.push("192k".into()); + } + } else { + args.push("copy".into()); + } + + // Bitrate limits (simple CRF/b:v approach) + if let Some(bitrate) = self.max_bitrate_kbps { + if self.video_codec.is_some() { + args.push("-maxrate".into()); + args.push(format!("{}k", bitrate)); + args.push("-bufsize".into()); + args.push(format!("{}k", bitrate * 2)); + } + } + + // Container / Format + args.push("-f".into()); + args.push(self.container.clone()); + + // Fast start for mp4 + if self.container == "mp4" { + // Fragmented MP4 can be consumed while FFmpeg is still producing it. + args.push("-movflags".into()); + args.push("frag_keyframe+empty_moov+default_base_moof".into()); + args.push("-avoid_negative_ts".into()); + args.push("make_zero".into()); + args.push("-muxdelay".into()); + args.push("0".into()); + args.push("-muxpreload".into()); + args.push("0".into()); + } + + // Output path or stdout + args.push("-y".into()); + args.push(output_target.into()); + + args + } +} + +/// Spawns a transcode process and returns it. +pub async fn spawn_transcode( + _session_id: &str, + spec: &TranscodeSpec, + settings: &FfmpegSettings, +) -> Result { + if let Some(parent) = spec.output_path.parent() { + fs::create_dir_all(parent).await?; + } + + let args = spec.to_ffmpeg_args(); + + log::info!( + "Starting FFmpeg: {} {}", + settings.ffmpeg_path, + args.join(" ") + ); + + let mut command = Command::new(&settings.ffmpeg_path); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let child = command.spawn()?; + + Ok(child) +} + +/// Spawns a transcode process that writes a fragmented stream to stdout. +pub async fn spawn_transcode_stdout( + _session_id: &str, + spec: &TranscodeSpec, + settings: &FfmpegSettings, +) -> Result { + let args = spec.to_ffmpeg_stdout_args(); + + log::info!( + "Starting FFmpeg stdout stream: {} {}", + settings.ffmpeg_path, + args.join(" ") + ); + + let mut command = Command::new(&settings.ffmpeg_path); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = command.spawn()?; + + Ok(child) +} diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index 6ba73a90..095373ba 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -1,20 +1,42 @@ //! Tray icon utilities for the application. +// standard imports +use std::path::{ + Path, + PathBuf, +}; + // lib imports +#[cfg(target_os = "windows")] +use tao::platform::windows::EventLoopBuilderExtWindows; use tao::{ event::Event, - event_loop::{ControlFlow, EventLoopBuilder}, + event_loop::{ + ControlFlow, + EventLoopBuilder, + }, + platform::run_return::EventLoopExtRunReturn, }; use tray_icon::{ TrayIconBuilder, TrayIconEvent, - menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, + menu::{ + AboutMetadata, + Menu, + MenuEvent, + MenuItem, + PredefinedMenuItem, + Submenu, + }, }; // local imports use crate::globals; use crate::signal_handler::ShutdownSignal; +const KOKO_ASSETS_DIR_ENV: &str = "KOKO_ASSETS_DIR"; +const ICON_FILE_NAME: &str = "icon.ico"; + #[derive(Debug)] enum UserEvent { TrayIconEvent(TrayIconEvent), @@ -23,9 +45,12 @@ enum UserEvent { /// Launch the tray icon and event loop with graceful shutdown support. pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { - let path = std::path::Path::new(globals::GLOBAL_ICON_ICO_PATH); + let path = icon_path(); - let event_loop = EventLoopBuilder::::with_user_event().build(); + let mut event_loop_builder = EventLoopBuilder::::with_user_event(); + #[cfg(target_os = "windows")] + event_loop_builder.with_any_thread(true); + let mut event_loop = event_loop_builder.build(); // set a tray event handler that forwards the event and wakes up the event loop let proxy = event_loop.create_proxy(); @@ -107,12 +132,13 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { let mut tray_icon = None; - event_loop.run(move |event, _, control_flow| { + event_loop.run_return(move |event, _, control_flow| { // Always check for shutdown signal first and exit immediately if shutdown_signal.is_shutdown() { log::info!("Tray received shutdown signal, exiting immediately"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; + return; } // Use Poll with a short timeout to check shutdown frequently @@ -122,7 +148,7 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { match event { Event::NewEvents(tao::event::StartCause::Init) => { - let icon = load_icon(std::path::Path::new(path)); + let icon = load_icon(&path); // We create the icon once the event loop is actually running // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 @@ -157,7 +183,7 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { id if id == quit_i.id() => { log::info!("Quit requested from tray menu"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; } id if id == options_disable_tray_i.id() => { // TODO: adjust application config first @@ -198,15 +224,74 @@ pub fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { if shutdown_signal.is_shutdown() { log::info!("Tray event - shutdown detected, exiting immediately"); tray_icon.take(); - std::process::exit(0); + *control_flow = ControlFlow::Exit; } } } - }) + }); + + TrayIconEvent::set_event_handler(Option::::None); + MenuEvent::set_event_handler(Option::::None); +} + +fn icon_path() -> PathBuf { + icon_path_candidates() + .into_iter() + .find(|path| path.exists()) + .unwrap_or_else(source_icon_path) +} + +fn icon_path_candidates() -> Vec { + let configured_path = PathBuf::from(globals::GLOBAL_ICON_ICO_PATH); + + let mut candidates = Vec::new(); + + if let Ok(assets_dir) = std::env::var(KOKO_ASSETS_DIR_ENV) { + candidates.push(PathBuf::from(assets_dir).join(ICON_FILE_NAME)); + } + + candidates.push(configured_path.clone()); + + if let Ok(executable_path) = std::env::current_exe() { + if let Some(executable_dir) = executable_path.parent() { + candidates.push(executable_dir.join(&configured_path)); + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + for ancestor in executable_dir.ancestors() { + #[cfg(target_os = "macos")] + candidates.push( + ancestor + .join("Resources") + .join("assets") + .join(ICON_FILE_NAME), + ); + #[cfg(target_os = "linux")] + candidates.push( + ancestor + .join("share") + .join("koko") + .join("assets") + .join(ICON_FILE_NAME), + ); + } + } + } + } + + candidates.push(source_icon_path()); + candidates +} + +fn source_icon_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join(globals::GLOBAL_ICON_ICO_PATH) } /// Load an icon from a file path. -pub fn load_icon(path: &std::path::Path) -> tray_icon::Icon { +pub fn load_icon(path: &Path) -> tray_icon::Icon { let (icon_rgba, icon_width, icon_height) = { let image = image::open(path) .expect("Failed to open icon path") diff --git a/crates/server/src/utils.rs b/crates/server/src/utils.rs new file mode 100644 index 00000000..ed413583 --- /dev/null +++ b/crates/server/src/utils.rs @@ -0,0 +1,10 @@ +//! Shared utility helpers. + +/// Return the current Unix timestamp in seconds. +pub fn current_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or_default() +} diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs index 1078b842..d8f2ef10 100644 --- a/crates/server/src/web/mod.rs +++ b/crates/server/src/web/mod.rs @@ -1,19 +1,34 @@ //! Web server utilities for the application. // modules -mod routes; +pub(crate) mod routes; // lib imports +use diesel::Connection; use rocket::config::Config; use rocket::config::TlsConfig; +use rocket::fairing::AdHoc; use rocket::figment::Figment; use rocket_okapi::settings::UrlObject; -use rocket_okapi::{rapidoc::*, swagger_ui::*}; +use rocket_okapi::{ + rapidoc::*, + swagger_ui::*, +}; // local imports use crate::certs; -use crate::config::GLOBAL_SETTINGS; -use crate::db::{DbConn, Migrate}; +use crate::config::{ + current_settings, + load_database_settings, + replace_current_settings, + seed_database_settings, +}; +use crate::db::{ + DbConn, + Migrate, + ReleaseDatabase, + initialize_sqlite_database, +}; use crate::globals; use crate::signal_handler::ShutdownSignal; @@ -24,37 +39,68 @@ pub fn rocket() -> rocket::Rocket { /// Build the web server with a custom database path (primarily for testing). pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket { + let bootstrap_settings = current_settings(); + + // Use custom database path for tests, or default for production. + let db_path = custom_db_path.unwrap_or_else(|| globals::APP_PATHS.db_path.clone()); + let settings = match initialize_sqlite_database(&db_path) { + Ok(()) => match diesel::SqliteConnection::establish(&db_path) { + Ok(mut conn) => { + if let Err(error) = seed_database_settings(&mut conn, &bootstrap_settings) { + log::warn!("Failed to seed database-backed settings: {}", error); + } + match load_database_settings(&mut conn, &bootstrap_settings) { + Ok(settings) => { + replace_current_settings(settings.clone()); + settings + } + Err(error) => { + log::warn!("Failed to load database-backed settings: {}", error); + bootstrap_settings + } + } + } + Err(error) => { + log::warn!("Failed to reopen SQLite database for settings: {}", error); + bootstrap_settings + } + }, + Err(error) => { + log::warn!("{}", error); + bootstrap_settings + } + }; + // the cert path changes depending on if the user wants to use custom certs let (cert_path, key_path); - if !GLOBAL_SETTINGS.server.use_custom_certs { - cert_path = format!("{}/cert.pem", GLOBAL_SETTINGS.general.data_dir); - key_path = format!("{}/key.pem", GLOBAL_SETTINGS.general.data_dir); + if !settings.server.use_custom_certs { + cert_path = format!("{}/cert.pem", settings.general.data_dir); + key_path = format!("{}/key.pem", settings.general.data_dir); } else { - cert_path = GLOBAL_SETTINGS.server.cert_path.clone(); - key_path = GLOBAL_SETTINGS.server.key_path.clone(); + cert_path = settings.server.cert_path.clone(); + key_path = settings.server.key_path.clone(); } - if GLOBAL_SETTINGS.server.use_https { + if settings.server.use_https { certs::ensure_certificates_exist(cert_path.clone(), key_path.clone()); } - // Use custom database path for tests, or default for production - let db_path = custom_db_path.unwrap_or_else(|| globals::APP_PATHS.db_path.clone()); + let database_url = sqlite_database_url(&db_path); let figment = Figment::from(Config::default()) .merge(( "databases", rocket::figment::map! { "sqlite_db" => rocket::figment::map! { - "url" => format!("sqlite://{}", db_path), + "url" => database_url, } }, )) - .merge(("address", GLOBAL_SETTINGS.server.address.clone())) - .merge(("port", GLOBAL_SETTINGS.server.port)) + .merge(("address", settings.server.address.clone())) + .merge(("port", settings.server.port)) .merge(( "tls", - if GLOBAL_SETTINGS.server.use_https { + if settings.server.use_https { Some(TlsConfig::from_paths(cert_path, key_path)) } else { None @@ -64,7 +110,42 @@ pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket { + crate::scheduled_tasks::start_scheduled_task_runner(scheduled_tasks_db); + } + None => { + log::error!( + "Failed to acquire database connection for background worker startup" + ); + } + } + + let metadata_recovery_db = DbConn::get_one(rocket).await; + match metadata_recovery_db { + Some(metadata_recovery_db) => { + rocket::tokio::spawn(async move { + let settings = crate::config::current_settings(); + crate::web::routes::media::recover_pending_metadata_refreshes( + &metadata_recovery_db, + &settings, + ) + .await; + }); + } + None => { + log::error!( + "Failed to acquire database connection for metadata refresh recovery" + ); + } + } + }) + })) + .mount("/", routes::api_routes()) .mount( "/swagger-ui/", make_swagger_ui(&SwaggerUIConfig { @@ -90,14 +171,42 @@ pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket String { + if db_path == ":memory:" || db_path.starts_with("file:") { + return format!("sqlite://{db_path}"); + } + + let normalized = db_path.replace('\\', "/"); + let has_windows_drive = normalized + .as_bytes() + .get(1) + .is_some_and(|character| *character == b':'); + if has_windows_drive { + format!("sqlite:///{normalized}") + } else { + format!("sqlite://{normalized}") + } } /// Launch the web server with graceful shutdown support. pub async fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { - let rocket = rocket().ignite().await.expect("Failed to ignite rocket"); + launch_rocket_with_shutdown(rocket(), shutdown_signal).await; +} + +/// Launch a configured Rocket instance with graceful shutdown support. +pub async fn launch_rocket_with_shutdown( + rocket: rocket::Rocket, + shutdown_signal: ShutdownSignal, +) { + let rocket = rocket.ignite().await.expect("Failed to ignite rocket"); + let rocket_shutdown = rocket.shutdown(); // Start the rocket server let rocket_handle = rocket.launch(); + tokio::pin!(rocket_handle); // Clone the shutdown signal for the future let shutdown_signal_clone = shutdown_signal.clone(); @@ -112,7 +221,7 @@ pub async fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { // Race between the server and shutdown signal tokio::select! { - result = rocket_handle => { + result = &mut rocket_handle => { log::info!("Rocket server has shut down"); // Rocket shut down (likely due to SIGINT), signal other components to shut down shutdown_signal.shutdown(); @@ -122,6 +231,10 @@ pub async fn launch_with_shutdown(shutdown_signal: ShutdownSignal) { } _ = shutdown_future => { log::info!("Web server shutting down gracefully"); + rocket_shutdown.notify(); + if let Err(e) = rocket_handle.await { + log::error!("Web server error during graceful shutdown: {}", e); + } } } } diff --git a/crates/server/src/web/routes/auth.rs b/crates/server/src/web/routes/auth.rs index 01fcaf5e..f66c0338 100644 --- a/crates/server/src/web/routes/auth.rs +++ b/crates/server/src/web/routes/auth.rs @@ -3,15 +3,31 @@ // lib imports use diesel::QueryDsl; use diesel::RunQueryDsl; -use diesel::{ExpressionMethods, SelectableHelper}; +use diesel::{ + ExpressionMethods, + SelectableHelper, +}; use rocket::http::Status; -use rocket::serde::{Deserialize, Serialize, json::Json}; -use rocket::{get, post}; -use rocket_okapi::{JsonSchema, openapi}; +use rocket::serde::{ + Deserialize, + Serialize, + json::Json, +}; +use rocket::{ + get, + post, +}; +use rocket_okapi::{ + JsonSchema, + openapi, +}; use serde_json::json; // local imports -use crate::auth::{AdminGuard, UserGuard}; +use crate::auth::{ + AdminGuard, + UserGuard, +}; use crate::db::DbConn; use crate::db::models::User; diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index 357de165..ed9a9293 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -1,14 +1,157 @@ //! Routes for the web server. +// standard imports +use std::env; +use std::path::{ + Path, + PathBuf, +}; + // lib imports +use rocket::fs::NamedFile; use rocket::get; -use rocket_okapi::openapi; +use rocket::http::uri::{ + Segments, + fmt::Path as UriPath, +}; +use rocket::response::content::RawHtml; // local imports use crate::globals; -#[openapi(tag = "Index")] #[get("/")] -pub fn index() -> String { - format!("Welcome to {}!", globals::GLOBAL_APP_NAME) +pub async fn index() -> Result> { + let index_path = web_client_index_path(); + + if let Some(index_path) = index_path { + return NamedFile::open(index_path) + .await + .map_err(|_| RawHtml(web_client_missing_html())); + } + + Err(RawHtml(web_client_missing_html())) +} + +#[get("/", rank = 100)] +pub async fn spa_asset(path: Segments<'_, UriPath>) -> Option { + let dist_dir = web_client_dist_dir()?; + let requested_path = path.to_path_buf(false).ok(); + + if let Some(requested_path) = requested_path { + let requested_path = dist_dir.join(&requested_path); + if requested_path.is_file() { + return NamedFile::open(requested_path).await.ok(); + } + } + + let has_extension = path + .clone() + .last() + .is_some_and(|segment| Path::new(segment).extension().is_some()); + + if !has_extension { + return NamedFile::open(dist_dir.join("index.html")).await.ok(); + } + + None +} + +fn web_client_index_path() -> Option { + let dist_dir = web_client_dist_dir()?; + let index_path = dist_dir.join("index.html"); + index_path.is_file().then_some(index_path) +} + +fn web_client_dist_dir() -> Option { + web_client_dist_candidates() + .into_iter() + .find(|candidate| candidate.is_dir()) +} + +fn web_client_dist_candidates() -> Vec { + let mut candidates = Vec::new(); + + if let Ok(path) = env::var("KOKO_WEB_CLIENT_DIST") { + let path = path.trim(); + if !path.is_empty() { + candidates.push(PathBuf::from(path)); + } + } + + if let Ok(current_dir) = env::current_dir() { + candidates.push(current_dir.join("crates").join("client-web").join("dist")); + } + + if let Ok(executable_path) = env::current_exe() { + if let Some(executable_dir) = executable_path.parent() { + candidates.push( + executable_dir + .join("crates") + .join("client-web") + .join("dist"), + ); + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + for ancestor in executable_dir.ancestors() { + #[cfg(target_os = "macos")] + candidates.push(ancestor.join("Resources").join("client-web").join("dist")); + #[cfg(target_os = "linux")] + candidates.push(ancestor.join("share").join("koko").join("client-web")); + } + } + } + } + + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("client-web") + .join("dist"), + ); + candidates +} + +fn web_client_missing_html() -> String { + format!( + r#" + + + + + {0} + + + +
+

{0}

+

The web client bundle is not available yet.

+

Build crates/client-web and make sure the output exists at + crates/client-web/dist, or set KOKO_WEB_CLIENT_DIST + to a built client directory.

+
+ +"#, + globals::GLOBAL_APP_NAME + ) } diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs new file mode 100644 index 00000000..7ad23cd9 --- /dev/null +++ b/crates/server/src/web/routes/media.rs @@ -0,0 +1,4723 @@ +//! Media and system discovery routes. + +// lib imports +use std::collections::{ + HashMap, + HashSet, +}; +use std::io::SeekFrom; +use std::path::PathBuf; +use std::sync::atomic::{ + AtomicBool, + AtomicU64, + Ordering, +}; + +use once_cell::sync::Lazy; +use rocket::delete; +use rocket::fs::NamedFile; +use rocket::get; +use rocket::http::{ + ContentType, + Status, +}; +use rocket::outcome::Outcome; +use rocket::post; +use rocket::request::{ + FromRequest, + Request, +}; +use rocket::response::stream::ReaderStream; +use rocket::response::{ + self, + Responder, + Response, +}; +use rocket::serde::Deserialize; +use rocket::serde::json::Json; +use rocket::tokio::fs::File; +use rocket::tokio::io::{ + AsyncReadExt, + AsyncSeekExt, + Take, +}; +use rocket::tokio::process::ChildStdout; +use rocket_okapi::openapi; +use schemars::JsonSchema; +use serde::Serialize; +use strsim::normalized_levenshtein; + +// local imports +use crate::auth::UserGuard; +use crate::config::{ + MetadataProviderId, + Settings, + current_settings, +}; +use crate::db::DbConn; +use crate::db::models::ItemMetadataLink; +use crate::globals; +use crate::media::{ + MediaHome, + MediaItemDetail, + MediaItemSummary, + PersistedLibrarySummary, + PersistedMediaFileSummary, + PlaybackDecision, + ShowMetadataDescendantPlan, + ShowMetadataEpisodePlan, + ShowMetadataSeasonPlan, + TranscodingCapability, + apply_user_playback_context_to_detail, + delete_missing_media_items, + get_item_secondary_provider_references, + get_item_youtube_theme_collection_references, + get_library_files, + get_library_metadata_languages, + get_library_metadata_providers, + get_media_home_with_preferred_languages, + get_media_item, + get_media_item_summary, + get_media_item_with_preferred_languages, + get_persisted_library_summaries, + get_playback_decision, + get_preferred_item_artwork_metadata_link_for_languages, + get_preferred_item_metadata_link, + inspect_transcoding_capability, + library_exists, + list_automatic_metadata_candidates, + list_automatic_metadata_refresh_candidates, + list_library_settings, + list_media_item_children, + list_media_items, + list_media_items_for_user_with_preferred_languages, + mark_metadata_match_attempted, + preferred_audio_stream_index, + resolve_item_subtitle_path, + resolve_item_theme_song_path, + resolve_local_item_artwork_path, + resolve_media_item_source_path, + search_media_items_for_user_with_preferred_languages, + sync_persisted_library_catalog_for_library, + upsert_playback_progress, + upsert_show_metadata_descendant_items, + user_can_access_library, +}; +use crate::metadata::{ + ArtworkKind, + DEFAULT_METADATA_LOCALE, + ItemMetadataSummary, + MetadataCollectionSummary, + MetadataPersonCreditSummary, + MetadataPersonEnrichmentTarget, + MetadataPersonSummary, + MetadataProviderRole, + MetadataProviderStatus, + MetadataSearchResult, + MetadataSnapshotFetchOptions, + ProviderDescendantTarget, + ProviderEpisodeMetadataSnapshotFetch, + ProviderMetadataPerson, + StoredMetadataSnapshot, + expected_artwork_cache_path, + fetch_provider_episode_metadata_snapshot_for_locale_with_options, + fetch_provider_metadata_snapshot_for_locale_with_options, + fetch_provider_person_metadata_for_locale, + fetch_provider_season_metadata_snapshot_for_locale_with_options, + fetch_provider_secondary_collection_metadata, + fetch_provider_secondary_metadata, + get_item_metadata_summaries, + get_metadata_person_for_languages, + get_metadata_person_locale_peer_ids, + get_primary_item_metadata_link, + guess_provider_movie_match, + guess_provider_show_match, + list_due_item_metadata_links, + list_metadata_collection_summaries_with_preferred_languages, + list_metadata_people_for_library, + list_metadata_person_credit_summaries_for_person_ids, + list_pending_item_metadata_links, + list_provider_statuses, + load_provider_show_descendant_targets, + managed_metadata_asset_dir, + metadata_asset_db_path, + normalize_locale_key, + persist_item_metadata_assets, + persist_metadata_people_assets, + provider_locale_key, + provider_uses_localized_metadata, + resolve_metadata_asset_db_path, + search_metadata_people_with_preferred_languages, + search_provider, + set_item_metadata_refresh_state, + sort_item_metadata_summaries_for_languages, + try_cache_item_artwork, + update_cached_artwork_path, + update_metadata_person_details, + upsert_item_metadata_link, + upsert_item_metadata_snapshot_with_refresh_interval, + upsert_secondary_collection_theme_song_url, +}; +use crate::utils::current_timestamp; + +pub enum SessionStream { + File(RangedFile), + Transcode { + content_type: ContentType, + stdout: ChildStdout, + }, +} + +impl<'r> Responder<'r, 'static> for SessionStream { + fn respond_to( + self, + _request: &'r Request<'_>, + ) -> response::Result<'static> { + match self { + SessionStream::File(file) => file.respond_to(_request), + SessionStream::Transcode { + content_type, + stdout, + } => Response::build() + .header(content_type) + .streamed_body(ReaderStream::one(stdout)) + .ok(), + } + } +} + +pub struct RangedFile { + content_type: ContentType, + body: Take, + content_length: u64, + content_range: Option, +} + +impl<'r> Responder<'r, 'static> for RangedFile { + fn respond_to( + self, + _request: &'r Request<'_>, + ) -> response::Result<'static> { + let mut response = Response::build(); + response + .status(if self.content_range.is_some() { Status::PartialContent } else { Status::Ok }) + .header(self.content_type) + .raw_header("Accept-Ranges", "bytes") + .raw_header("Content-Length", self.content_length.to_string()) + .streamed_body(self.body); + if let Some(content_range) = self.content_range { + response.raw_header("Content-Range", content_range); + } + response.ok() + } +} + +pub struct RangeHeader(Option); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RangeHeader { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { + Outcome::Success(RangeHeader( + request.headers().get_one("Range").map(str::to_string), + )) + } +} + +#[derive(Clone, Copy)] +struct ByteRange { + start: u64, + end: u64, +} + +fn parse_byte_range( + header: &str, + total_length: u64, +) -> Option { + if total_length == 0 { + return None; + } + + let range = header + .trim() + .strip_prefix("bytes=")? + .split(',') + .next()? + .trim(); + let (start, end) = range.split_once('-')?; + if start.is_empty() { + let suffix_length = end.trim().parse::().ok()?.min(total_length); + return Some(ByteRange { + start: total_length.saturating_sub(suffix_length), + end: total_length - 1, + }); + } + + let start = start.trim().parse::().ok()?; + if start >= total_length { + return None; + } + + let end = if end.trim().is_empty() { + total_length - 1 + } else { + end.trim().parse::().ok()?.min(total_length - 1) + }; + (end >= start).then_some(ByteRange { start, end }) +} + +fn content_type_for_path(path: &std::path::Path) -> ContentType { + path.extension() + .and_then(|extension| extension.to_str()) + .and_then(ContentType::from_extension) + .unwrap_or(ContentType::Binary) +} + +async fn open_ranged_file( + path: PathBuf, + range: &RangeHeader, +) -> Result { + let metadata = rocket::tokio::fs::metadata(&path) + .await + .map_err(|_| Status::NotFound)?; + let total_length = metadata.len(); + let selected_range = range + .0 + .as_deref() + .and_then(|header| parse_byte_range(header, total_length)); + let byte_range = selected_range.unwrap_or_else(|| ByteRange { + start: 0, + end: total_length.saturating_sub(1), + }); + let content_length = if total_length == 0 { + 0 + } else { + byte_range + .end + .saturating_sub(byte_range.start) + .saturating_add(1) + }; + let mut file = File::open(&path).await.map_err(|_| Status::NotFound)?; + if byte_range.start > 0 { + file.seek(SeekFrom::Start(byte_range.start)) + .await + .map_err(|_| Status::InternalServerError)?; + } + + Ok(RangedFile { + content_type: content_type_for_path(&path), + body: file.take(content_length), + content_length, + content_range: selected_range + .map(|range| format!("bytes {}-{}/{}", range.start, range.end, total_length)), + }) +} + +async fn stop_active_transcode(session_id: &str) -> bool { + let handle = ACTIVE_TRANSCODE_TASKS.lock().await.remove(session_id); + if let Some(handle) = handle { + handle.abort(); + true + } else { + false + } +} + +async fn replace_active_transcode( + session_id: String, + handle: tokio::task::JoinHandle<()>, +) { + if let Some(previous_handle) = ACTIVE_TRANSCODE_TASKS + .lock() + .await + .insert(session_id, handle) + { + previous_handle.abort(); + } +} + +/// Capability summary returned to clients during bootstrap. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ServerCapabilitiesResponse { + /// Application name. + pub app_name: String, + /// Current server version. + pub version: String, + /// Base server URL derived from the current settings. + pub server_url: String, + /// Whether HTTPS is enabled. + pub https_enabled: bool, + /// Number of configured libraries. + pub libraries_configured: usize, + /// Supported API versions. + pub api_versions: Vec, + /// Current transcoding-tool capability details. + pub transcoding: TranscodingCapability, +} + +/// Metadata response for one browser-facing media item. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ItemMetadataResponse { + /// Stable database identifier for the item. + pub item_id: i32, + /// Provider statuses visible to the current server configuration. + pub providers: Vec, + /// Stored metadata matches for the item. + pub matches: Vec, +} + +/// Browser-facing person detail with related media credits. +#[derive(Debug, Serialize, JsonSchema)] +pub struct MetadataPersonResponse { + /// Normalized person record. + pub person: MetadataPersonSummary, + /// Media items linked to this person. + pub credits: Vec, +} + +/// One media item credit for a person. +#[derive(Debug, Serialize, JsonSchema)] +pub struct MetadataPersonItemCredit { + /// Stored credit details. + pub credit: MetadataPersonCreditSummary, + /// Related media item. + pub item: MediaItemSummary, + /// Breadcrumb-like media hierarchy for the credited item. + pub hierarchy: Vec, +} + +/// Browser-facing mixed search result. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(tag = "result_type", rename_all = "snake_case")] +pub enum MediaSearchResult { + /// Media item result such as a movie, show, season, episode, or future item type. + Item { + /// Matching media item. + item: MediaItemSummary, + }, + /// Collection grouping result. + Collection { + /// Matching collection. + collection: MetadataCollectionSummary, + }, + /// Metadata person result. + Person { + /// Matching person. + person: MetadataPersonSummary, + }, +} + +/// Active backend activity summary that the browser can poll. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SystemActivity { + /// Stable activity identifier. + pub id: String, + /// High-level activity category. + pub category: String, + /// Activity scope such as `item` or `library`. + pub scope: String, + /// Activity source such as `manual_item_refresh`. + pub source: String, + /// Current activity state such as `queued` or `running`. + pub state: String, + /// Human-friendly label for the activity. + pub label: String, + /// Provider identifier when the activity is metadata-related. + pub provider_id: Option, + /// Owning library identifier, when known. + pub library_id: Option, + /// Root item identifier for item-scoped work, when known. + pub root_item_id: Option, + /// All item identifiers currently tracked by the activity. + pub item_ids: Vec, + /// Total number of tracked items. + pub total_items: i32, + /// Number of completed item refreshes. + pub completed_items: i32, + /// Number of failed item refreshes. + pub failed_items: i32, + /// Unix timestamp when the activity was queued. + pub queued_at: i64, + /// Unix timestamp when the activity first started running. + pub started_at: Option, + /// Unix timestamp for the latest activity update. + pub updated_at: i64, +} + +/// Pollable activity response for the browser client. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SystemActivitiesResponse { + /// Unix timestamp when the snapshot was generated. + pub generated_at: i64, + /// Active activities currently tracked by the backend. + pub activities: Vec, +} + +/// Response returned after deleting missing catalog items. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct MissingItemsCleanupResponse { + /// Library scoped by the cleanup request. + pub library_id: i32, + /// File rows removed from the active catalog. + pub deleted_files: i64, + /// Item rows removed from the active catalog. + pub deleted_items: i64, + /// Collection membership rows removed from active collection/list views. + pub removed_collection_items: i64, + /// Refreshed library summary after cleanup. + pub library: PersistedLibrarySummary, +} + +/// One locale supported by Koko metadata preferences. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct MetadataLocale { + /// Koko locale key. + pub key: String, + /// Human-friendly display name. + pub name: String, + /// TMDB locale key. + pub tmdb: String, + /// TheTVDB locale key. + pub tvdb: String, +} + +/// Playback progress payload from the browser client. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PlaybackProgressRequest { + /// Current playback position in milliseconds. + pub position_ms: i64, + /// Current known duration in milliseconds, when available. + pub duration_ms: Option, + /// Whether playback has completed. + pub completed: bool, +} + +/// Request payload for linking a media item to provider metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LinkMetadataRequest { + /// Provider to link. + pub provider_id: MetadataProviderId, + /// Provider-side external identifier. + pub external_id: String, + /// Provider-specific media type such as `movie` or `tv`. + pub media_type: String, +} + +/// Request to start a playback session. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateSessionRequest { + pub item_id: i32, + pub client_profile: crate::media::ClientProfile, +} + +static NEXT_SYSTEM_ACTIVITY_ID: Lazy = Lazy::new(|| AtomicU64::new(1)); +static ACTIVE_SYSTEM_ACTIVITIES: Lazy< + tokio::sync::RwLock>, +> = Lazy::new(|| tokio::sync::RwLock::new(HashMap::new())); +static ACTIVE_METADATA_REFRESH_ITEMS: Lazy>> = + Lazy::new(|| tokio::sync::RwLock::new(HashMap::new())); +static ACTIVE_METADATA_REFRESH_EXECUTIONS: Lazy>> = + Lazy::new(|| tokio::sync::RwLock::new(HashSet::new())); +static ACTIVE_LIBRARY_METADATA_REFRESHES: Lazy>> = + Lazy::new(|| tokio::sync::RwLock::new(HashSet::new())); +static ACTIVE_MANUAL_CATALOG_SCAN_RUNNING: Lazy = Lazy::new(|| AtomicBool::new(false)); +static ACTIVE_PLAYBACK_SESSIONS: Lazy< + tokio::sync::RwLock>, +> = Lazy::new(|| tokio::sync::RwLock::new(HashMap::new())); +static ACTIVE_TRANSCODE_TASKS: Lazy< + tokio::sync::Mutex>>, +> = Lazy::new(|| tokio::sync::Mutex::new(HashMap::new())); + +#[derive(Debug, Clone)] +struct MetadataRefreshActivityRecord { + activity: SystemActivity, +} + +fn next_system_activity_id() -> String { + format!( + "activity-{}", + NEXT_SYSTEM_ACTIVITY_ID.fetch_add(1, Ordering::SeqCst) + ) +} + +fn begin_catalog_scan_execution() -> bool { + ACTIVE_MANUAL_CATALOG_SCAN_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() +} + +fn finish_catalog_scan_execution() { + ACTIVE_MANUAL_CATALOG_SCAN_RUNNING.store(false, Ordering::SeqCst); +} + +fn metadata_refresh_interval_seconds(settings: &Settings) -> Option { + settings + .metadata + .refresh_interval_days + .and_then(|days| i64::from(days).checked_mul(24 * 60 * 60)) +} + +fn metadata_locales_for_provider( + provider_id: MetadataProviderId, + languages: &[String], +) -> Vec { + let source_languages = if provider_uses_localized_metadata(provider_id) { + languages.to_vec() + } else { + vec![DEFAULT_METADATA_LOCALE.to_string()] + }; + + let mut seen = HashSet::new(); + let mut locales = source_languages + .into_iter() + .map(|language| normalize_locale_key(&language)) + .filter(|language| seen.insert(language.clone())) + .collect::>(); + if locales.is_empty() { + locales.push(DEFAULT_METADATA_LOCALE.to_string()); + } + locales +} + +fn secondary_providers_for_library( + settings: &Settings, + library_providers: &[MetadataProviderId], +) -> Vec { + list_provider_statuses(&settings.metadata) + .into_iter() + .filter(|provider| { + provider.role == MetadataProviderRole::Secondary + && provider.configured + && provider.implemented + && library_providers.contains(&provider.id) + && provider + .extends_provider_ids + .iter() + .any(|primary_provider| library_providers.contains(primary_provider)) + }) + .map(|provider| provider.id) + .collect() +} + +async fn persist_secondary_metadata_for_item( + db: &DbConn, + item_id: i32, + settings: &Settings, +) -> Result<(), Status> { + let library_id = db + .run(move |conn| { + get_media_item_summary(conn, item_id)? + .map(|item| item.library_id) + .ok_or(diesel::result::Error::NotFound) + }) + .await + .map_err(|error| match error { + diesel::result::Error::NotFound => Status::NotFound, + error => { + log::error!( + "Failed to load library for media item {} secondary provider metadata: {}", + item_id, + error + ); + Status::InternalServerError + } + })?; + let library_providers = load_item_library_metadata_providers(db, library_id).await?; + let secondary_providers = secondary_providers_for_library(settings, &library_providers); + if secondary_providers.is_empty() { + return Ok(()); + } + let library_languages = load_item_library_metadata_languages(db, library_id).await?; + + for provider_id in secondary_providers { + let uses_localized_metadata = provider_uses_localized_metadata(provider_id.clone()); + let languages = metadata_locales_for_provider(provider_id.clone(), &library_languages); + let references = db + .run({ + let provider_id = provider_id.clone(); + move |conn| get_item_secondary_provider_references(conn, item_id, provider_id) + }) + .await + .map_err(|error| { + log::error!( + "Failed to resolve secondary metadata references for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + for locale_key in &languages { + let provider_locale = uses_localized_metadata + .then(|| provider_locale_key(provider_id.clone(), locale_key)); + for (item_type, database_id, external_id) in &references { + match fetch_provider_secondary_metadata( + provider_id.clone(), + item_type, + database_id, + external_id, + locale_key, + ) + .await + { + Ok(Some(details)) => { + db.run({ + let provider_id = provider_id.clone(); + let item_type = item_type.clone(); + let database_id = database_id.clone(); + let external_id = external_id.clone(); + let locale_key = locale_key.clone(); + let provider_locale = provider_locale.clone(); + let details = details.clone(); + let refresh_interval_seconds = + metadata_refresh_interval_seconds(settings); + move |conn| { + let snapshot = StoredMetadataSnapshot { + provider_id, + external_id: format!("{item_type}:{database_id}:{external_id}"), + media_type: Some(item_type), + title: None, + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key, + provider_locale_key: provider_locale, + provider_payload_json: None, + }; + upsert_item_metadata_link( + conn, + item_id, + &snapshot, + &details, + "secondary", + refresh_interval_seconds, + ) + } + }) + .await + .map_err(|error| { + log::error!( + "Failed to persist secondary metadata for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + break; + } + Ok(None) => {} + Err(error) => { + log::warn!( + "Failed to load {} secondary metadata for media item {} locale {} ({} \ + {} {}): {}", + provider_id.as_storage_value(), + item_id, + locale_key, + item_type, + database_id, + external_id, + error + ); + } + } + } + } + + let collection_references = db + .run({ + let provider_id = provider_id.clone(); + move |conn| get_item_youtube_theme_collection_references(conn, item_id, provider_id) + }) + .await + .map_err(|error| { + log::error!( + "Failed to resolve secondary collection theme-song references for media item \ + {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + for (collection_id, item_type, database_id, external_id) in collection_references { + match fetch_provider_secondary_collection_metadata( + provider_id.clone(), + &item_type, + &database_id, + &external_id, + crate::metadata::DEFAULT_METADATA_LOCALE, + ) + .await + { + Ok(Some(collection)) => { + let Some(url) = collection.theme_song_url else { + continue; + }; + db.run({ + let provider_id = provider_id.clone(); + let item_type = item_type.clone(); + let database_id = database_id.clone(); + let external_id = external_id.clone(); + move |conn| { + upsert_secondary_collection_theme_song_url( + conn, + collection_id, + provider_id, + &item_type, + &database_id, + &external_id, + &url, + ) + } + }) + .await + .map_err(|error| { + log::error!( + "Failed to persist secondary collection theme-song metadata for media \ + item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + break; + } + Ok(None) => {} + Err(error) => { + log::warn!( + "Failed to load {} secondary collection metadata for media item {} ({} {} \ + {}): {}", + provider_id.as_storage_value(), + item_id, + item_type, + database_id, + external_id, + error + ); + } + } + } + } + + Ok(()) +} + +async fn persist_snapshot_for_item( + db: &DbConn, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + settings: &Settings, + options: PersistSnapshotOptions, +) -> Result { + let snapshot = if options.cache_person_assets { + persist_metadata_people_assets(snapshot, &settings.general.data_dir) + .await + .map_err(|error| { + log::error!( + "Failed to persist metadata people assets for media item {}: {}", + item_id, + error + ); + Status::BadGateway + })? + } else { + snapshot.clone() + }; + let (poster_path, backdrop_path, logo_path) = + persist_item_metadata_assets(&snapshot, item_id, &settings.general.data_dir) + .await + .map_err(|error| { + log::error!( + "Failed to persist metadata assets for media item {}: {}", + item_id, + error + ); + Status::BadGateway + })?; + + let mut summary = db + .run({ + let snapshot = snapshot.clone(); + let refresh_interval_seconds = metadata_refresh_interval_seconds(settings); + move |conn| { + upsert_item_metadata_snapshot_with_refresh_interval( + conn, + item_id, + &snapshot, + refresh_interval_seconds, + ) + } + }) + .await + .map_err(|error| { + log::error!( + "Failed to persist linked metadata for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + if let Some(poster_path) = poster_path { + let summary_id = summary.id; + let poster_path_string = metadata_asset_db_path(&settings.general.data_dir, &poster_path); + let data_dir = settings.general.data_dir.clone(); + db.run(move |conn| { + update_cached_artwork_path( + conn, + summary_id, + ArtworkKind::Poster, + &poster_path, + &data_dir, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to store poster cache path for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + summary.cached_artwork_path = Some(poster_path_string); + } + + if let Some(backdrop_path) = backdrop_path { + let summary_id = summary.id; + let backdrop_path_string = + metadata_asset_db_path(&settings.general.data_dir, &backdrop_path); + let data_dir = settings.general.data_dir.clone(); + db.run(move |conn| { + update_cached_artwork_path( + conn, + summary_id, + ArtworkKind::Backdrop, + &backdrop_path, + &data_dir, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to store backdrop cache path for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + summary.cached_backdrop_path = Some(backdrop_path_string); + } + + if let Some(logo_path) = logo_path { + let summary_id = summary.id; + let logo_path_string = metadata_asset_db_path(&settings.general.data_dir, &logo_path); + let data_dir = settings.general.data_dir.clone(); + db.run(move |conn| { + update_cached_artwork_path(conn, summary_id, ArtworkKind::Logo, &logo_path, &data_dir) + }) + .await + .map_err(|error| { + log::error!( + "Failed to store logo cache path for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + summary.cached_logo_path = Some(logo_path_string); + } + + persist_secondary_metadata_for_item(db, item_id, settings).await?; + + Ok(summary) +} + +fn supports_manual_metadata_linking(item: &MediaItemSummary) -> bool { + matches!(item.item_type.as_str(), "movie" | "show") +} + +fn provider_search_media_type( + provider_id: MetadataProviderId, + item: &MediaItemSummary, +) -> Option<&'static str> { + match (provider_id, item.item_type.as_str()) { + (_, "movie") => Some("movie"), + (MetadataProviderId::Tmdb, "show") => Some("tv"), + (MetadataProviderId::Tvdb, "show") => Some("series"), + _ => None, + } +} + +fn parse_metadata_provider_selection(value: Option) -> Vec { + value + .unwrap_or_default() + .split(',') + .filter_map(|provider| MetadataProviderId::from_storage_value(provider.trim())) + .collect() +} + +fn metadata_search_score( + query: &str, + year: Option, + result: &MetadataSearchResult, +) -> f64 { + let query_title = query.trim(); + let result_title = result.title.trim(); + let title_score = normalized_levenshtein( + &query_title.to_ascii_lowercase(), + &result_title.to_ascii_lowercase(), + ); + let year_score = match (year, result.release_year) { + (Some(left), Some(right)) if left == right => 0.16, + (Some(left), Some(right)) => { + let distance = (left - right).abs() as f64; + -(0.06 + distance.min(12.0) * 0.035) + } + (Some(_), None) => -0.05, + _ => 0.0, + }; + let casing_score = if query_title == result_title { + 0.03 + } else { + metadata_search_casing_penalty(query_title, result_title, &result.provider_id) + }; + ((title_score + year_score).clamp(0.0, 1.0) + casing_score).clamp(0.0, 1.0) +} + +fn metadata_search_casing_penalty( + query_title: &str, + result_title: &str, + provider_id: &MetadataProviderId, +) -> f64 { + if !matches!( + provider_id, + MetadataProviderId::Tmdb | MetadataProviderId::Tvdb + ) { + return 0.0; + } + + let comparable_letters = query_title + .chars() + .zip(result_title.chars()) + .filter(|(left, right)| { + left.is_ascii_alphabetic() + && right.is_ascii_alphabetic() + && left.eq_ignore_ascii_case(right) + }) + .count(); + if comparable_letters == 0 { + return 0.0; + } + + let mismatched_case_letters = query_title + .chars() + .zip(result_title.chars()) + .filter(|(left, right)| { + left.is_ascii_alphabetic() + && right.is_ascii_alphabetic() + && left.eq_ignore_ascii_case(right) + && left != right + }) + .count(); + if mismatched_case_letters == 0 { + return 0.0; + } + + let mismatch_ratio = mismatched_case_letters as f64 / comparable_letters as f64; + -(0.04 + mismatch_ratio * 0.06) +} + +#[derive(Debug, Clone)] +enum MetadataRefreshFetchKind { + Direct, + TmdbShowSeason { + show_external_id: String, + season_number: i32, + }, + TmdbShowEpisode { + show_external_id: String, + season_number: i32, + episode_number: i32, + }, + TvdbSeason { + show_external_id: String, + season_number: i32, + season_external_id: String, + }, + TvdbEpisode { + show_external_id: String, + season_number: i32, + episode_number: i32, + episode_external_id: String, + }, +} + +impl MetadataRefreshFetchKind { + fn snapshot_fetch_options(&self) -> MetadataSnapshotFetchOptions { + match self { + Self::Direct => MetadataSnapshotFetchOptions::WITHOUT_PERSON_DETAILS, + Self::TmdbShowSeason { .. } + | Self::TmdbShowEpisode { .. } + | Self::TvdbSeason { .. } + | Self::TvdbEpisode { .. } => MetadataSnapshotFetchOptions::WITHOUT_PERSON_DETAILS, + } + } +} + +#[derive(Debug, Clone)] +struct MetadataRefreshTarget { + item_id: i32, + library_id: i32, + provider_id: MetadataProviderId, + item_type: String, + display_title: String, + relative_path: String, + external_id: String, + media_type: String, + fetch_kind: MetadataRefreshFetchKind, +} + +#[derive(Debug, Clone)] +struct MetadataRefreshJob { + root: MetadataRefreshTarget, + descendants: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct PersistSnapshotOptions { + cache_person_assets: bool, +} + +impl PersistSnapshotOptions { + const WITHOUT_PERSON_ASSETS: Self = Self { + cache_person_assets: false, + }; + + fn for_target(_target: &MetadataRefreshTarget) -> Self { + Self::WITHOUT_PERSON_ASSETS + } +} + +fn describe_metadata_refresh_target(target: &MetadataRefreshTarget) -> String { + format!( + "media item {} \"{}\" ({}) in library {} [{}]", + target.item_id, + target.display_title, + target.item_type, + target.library_id, + target.relative_path + ) +} + +fn flatten_metadata_refresh_job(job: &MetadataRefreshJob) -> Vec { + let mut targets = Vec::with_capacity(1 + job.descendants.len()); + targets.push(job.root.clone()); + targets.extend(job.descendants.clone()); + targets +} + +async fn begin_metadata_refresh_execution(item_id: i32) -> bool { + ACTIVE_METADATA_REFRESH_EXECUTIONS + .write() + .await + .insert(item_id) +} + +async fn finish_metadata_refresh_execution(item_id: i32) { + ACTIVE_METADATA_REFRESH_EXECUTIONS + .write() + .await + .remove(&item_id); +} + +async fn begin_library_metadata_refresh(library_id: i32) -> bool { + ACTIVE_LIBRARY_METADATA_REFRESHES + .write() + .await + .insert(library_id) +} + +async fn finish_library_metadata_refresh(library_id: i32) { + ACTIVE_LIBRARY_METADATA_REFRESHES + .write() + .await + .remove(&library_id); +} + +async fn register_library_scan_activity( + library_id: i32, + library_name: &str, +) -> String { + let activity_id = next_system_activity_id(); + let now = current_timestamp(); + ACTIVE_SYSTEM_ACTIVITIES.write().await.insert( + activity_id.clone(), + MetadataRefreshActivityRecord { + activity: SystemActivity { + id: activity_id.clone(), + category: "library_scan".into(), + scope: "library".into(), + source: "manual_library_scan".into(), + state: "queued".into(), + label: format!("Scan library catalog for {library_name}"), + provider_id: None, + library_id: Some(library_id), + root_item_id: None, + item_ids: Vec::new(), + total_items: 1, + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: None, + updated_at: now, + }, + }, + ); + activity_id +} + +async fn mark_library_scan_activity_running(activity_id: &str) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.state = "running".into(); + record + .activity + .started_at + .get_or_insert_with(current_timestamp); + record.activity.updated_at = current_timestamp(); + } +} + +async fn complete_library_scan_activity( + activity_id: &str, + failed: bool, +) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.state = if failed { "failed" } else { "completed" }.into(); + record.activity.completed_items = 1; + record.activity.failed_items = if failed { 1 } else { 0 }; + record.activity.updated_at = current_timestamp(); + } + ACTIVE_SYSTEM_ACTIVITIES.write().await.remove(activity_id); +} + +async fn register_metadata_refresh_activity( + scope: &str, + source: &str, + label: String, + library_id: Option, + root_item_id: Option, + targets: Vec, +) -> Option<(String, Vec)> { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + let mut seen_item_ids = HashSet::new(); + let queued_targets = targets + .into_iter() + .filter(|target| seen_item_ids.insert(target.item_id)) + .filter(|target| !item_registry.contains_key(&target.item_id)) + .collect::>(); + if queued_targets.is_empty() { + return None; + } + + let activity_id = next_system_activity_id(); + for target in &queued_targets { + item_registry.insert(target.item_id, activity_id.clone()); + } + drop(item_registry); + + let now = current_timestamp(); + ACTIVE_SYSTEM_ACTIVITIES.write().await.insert( + activity_id.clone(), + MetadataRefreshActivityRecord { + activity: SystemActivity { + id: activity_id.clone(), + category: "metadata_refresh".into(), + scope: scope.into(), + source: source.into(), + state: "queued".into(), + label, + provider_id: queued_targets + .first() + .map(|target| target.provider_id.as_storage_value().to_string()), + library_id, + root_item_id, + item_ids: queued_targets.iter().map(|target| target.item_id).collect(), + total_items: i32::try_from(queued_targets.len()).unwrap_or(i32::MAX), + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: None, + updated_at: now, + }, + }, + ); + + Some((activity_id, queued_targets)) +} + +async fn extend_metadata_refresh_activity( + activity_id: &str, + targets: Vec, +) -> Vec { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + let mut seen_item_ids = HashSet::new(); + let queued_targets = targets + .into_iter() + .filter(|target| seen_item_ids.insert(target.item_id)) + .filter(|target| !item_registry.contains_key(&target.item_id)) + .collect::>(); + if queued_targets.is_empty() { + return Vec::new(); + } + + for target in &queued_targets { + item_registry.insert(target.item_id, activity_id.to_string()); + } + drop(item_registry); + + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record + .activity + .item_ids + .extend(queued_targets.iter().map(|target| target.item_id)); + record.activity.total_items = + i32::try_from(record.activity.item_ids.len()).unwrap_or(i32::MAX); + record.activity.updated_at = current_timestamp(); + } + + queued_targets +} + +async fn register_metadata_refresh_activity_for_items( + scope: &str, + source: &str, + label: String, + library_id: Option, + root_item_id: Option, + provider_id: Option, + item_ids: Vec, +) -> Option<(String, Vec)> { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + let mut seen_item_ids = HashSet::new(); + let queued_item_ids = item_ids + .into_iter() + .filter(|item_id| seen_item_ids.insert(*item_id)) + .filter(|item_id| !item_registry.contains_key(item_id)) + .collect::>(); + if queued_item_ids.is_empty() { + return None; + } + + let activity_id = next_system_activity_id(); + for item_id in &queued_item_ids { + item_registry.insert(*item_id, activity_id.clone()); + } + drop(item_registry); + + let now = current_timestamp(); + ACTIVE_SYSTEM_ACTIVITIES.write().await.insert( + activity_id.clone(), + MetadataRefreshActivityRecord { + activity: SystemActivity { + id: activity_id.clone(), + category: "metadata_refresh".into(), + scope: scope.into(), + source: source.into(), + state: "queued".into(), + label, + provider_id: provider_id + .as_ref() + .map(|provider_id| provider_id.as_storage_value().to_string()), + library_id, + root_item_id, + item_ids: queued_item_ids.clone(), + total_items: i32::try_from(queued_item_ids.len()).unwrap_or(i32::MAX), + completed_items: 0, + failed_items: 0, + queued_at: now, + started_at: None, + updated_at: now, + }, + }, + ); + + Some((activity_id, queued_item_ids)) +} + +async fn register_manual_library_automatch_activity( + db: &DbConn, + library_id: i32, +) -> Option<(String, Vec)> { + let candidates = match db + .run(move |conn| { + list_automatic_metadata_refresh_candidates(conn, Some(library_id), usize::MAX) + }) + .await + { + Ok(candidates) => candidates, + Err(error) => { + log::warn!( + "Failed to load automatic metadata candidates for library {} refresh activity: {}", + library_id, + error + ); + return None; + } + }; + let item_ids = candidates + .iter() + .map(|candidate| candidate.item_id) + .collect::>(); + + register_metadata_refresh_activity_for_items( + "library", + "manual_library_automatch", + "Match unlinked library metadata".into(), + Some(library_id), + None, + None, + item_ids, + ) + .await +} + +async fn cancel_metadata_refresh_activity(activity_id: &str) { + let removed = ACTIVE_SYSTEM_ACTIVITIES.write().await.remove(activity_id); + if let Some(record) = removed { + let mut item_registry = ACTIVE_METADATA_REFRESH_ITEMS.write().await; + for item_id in &record.activity.item_ids { + if item_registry.get(item_id).map(|value| value.as_str()) == Some(activity_id) { + item_registry.remove(item_id); + } + } + } +} + +async fn mark_metadata_refresh_activity_running(activity_id: &str) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.state = "running".into(); + record + .activity + .started_at + .get_or_insert_with(current_timestamp); + record.activity.updated_at = current_timestamp(); + } +} + +async fn record_metadata_refresh_activity_progress( + activity_id: &str, + failed: bool, +) { + if let Some(record) = ACTIVE_SYSTEM_ACTIVITIES.write().await.get_mut(activity_id) { + record.activity.completed_items += 1; + if failed { + record.activity.failed_items += 1; + } + record.activity.updated_at = current_timestamp(); + } +} + +async fn complete_metadata_refresh_activity(activity_id: &str) { + cancel_metadata_refresh_activity(activity_id).await; +} + +async fn current_system_activities() -> Vec { + let activities = ACTIVE_SYSTEM_ACTIVITIES.read().await; + let mut snapshot = activities + .values() + .map(|record| record.activity.clone()) + .collect::>(); + snapshot.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.label.cmp(&right.label)) + }); + snapshot +} + +async fn load_show_descendant_refresh_targets( + db: &DbConn, + settings: &crate::config::Settings, + show_item_id: i32, + provider_id: MetadataProviderId, + show_external_id: &str, +) -> Result, Status> { + let lookup = load_provider_show_descendant_targets( + &settings.metadata, + provider_id.clone(), + show_external_id, + ) + .await + .map_err(|error| { + log::error!( + "Failed to load {} descendant metadata for show item {}: {}", + provider_id.as_storage_value(), + show_item_id, + error + ); + Status::ServiceUnavailable + })?; + if lookup.is_empty() { + return Ok(Vec::new()); + } + + let descendant_plan = show_metadata_descendant_plan(&lookup); + let descendant_items = db + .run(move |conn| { + upsert_show_metadata_descendant_items(conn, show_item_id, &descendant_plan) + }) + .await + .map_err(|error| { + log::error!( + "Failed to upsert missing show descendants for metadata propagation on item {}: {}", + show_item_id, + error + ); + Status::InternalServerError + })?; + + let mut targets = Vec::new(); + for (season_number, season_target) in provider_season_targets_by_number(&lookup) { + let Some(season) = descendant_items + .seasons_by_number + .get(&season_number) + .cloned() + else { + continue; + }; + if let Some(target) = metadata_refresh_target_for_show_season( + provider_id.clone(), + show_external_id, + season, + &season_target, + ) { + targets.push(target); + } + + if !descendant_items + .seasons_with_local_episodes + .contains(&season_number) + { + continue; + } + + let mut episode_targets = lookup + .iter() + .filter(|target| target.season_number == season_number && target.episode_number > 0) + .cloned() + .collect::>(); + episode_targets.sort_by_key(|target| target.episode_number); + episode_targets.dedup_by_key(|target| target.episode_number); + for episode_target in episode_targets { + let Some(episode) = descendant_items + .episodes_by_number + .get(&(season_number, episode_target.episode_number)) + .cloned() + else { + continue; + }; + if let Some(target) = metadata_refresh_target_for_show_episode( + provider_id.clone(), + show_external_id, + episode, + &episode_target, + ) { + targets.push(target); + } + } + } + + Ok(targets) +} + +fn show_metadata_descendant_plan( + lookup: &[ProviderDescendantTarget] +) -> ShowMetadataDescendantPlan { + let mut season_numbers = HashSet::new(); + let mut episode_numbers = HashSet::new(); + for target in lookup { + if target.season_number > 0 { + season_numbers.insert(target.season_number); + } + if target.season_number > 0 && target.episode_number > 0 { + episode_numbers.insert((target.season_number, target.episode_number)); + } + } + + let mut seasons = season_numbers + .into_iter() + .map(|season_number| ShowMetadataSeasonPlan { + season_number, + display_title: None, + }) + .collect::>(); + seasons.sort_by_key(|season| season.season_number); + + let mut episodes = episode_numbers + .into_iter() + .map(|(season_number, episode_number)| ShowMetadataEpisodePlan { + season_number, + episode_number, + display_title: None, + }) + .collect::>(); + episodes.sort_by_key(|episode| (episode.season_number, episode.episode_number)); + + ShowMetadataDescendantPlan { seasons, episodes } +} + +fn provider_season_targets_by_number( + lookup: &[ProviderDescendantTarget] +) -> Vec<(i32, ProviderDescendantTarget)> { + let mut by_number = HashMap::new(); + for target in lookup.iter().filter(|target| target.season_number > 0) { + by_number + .entry(target.season_number) + .or_insert_with(|| target.clone()); + } + + let mut seasons = by_number.into_iter().collect::>(); + seasons.sort_by_key(|(season_number, _)| *season_number); + seasons +} + +fn metadata_refresh_target_for_show_season( + provider_id: MetadataProviderId, + show_external_id: &str, + season: MediaItemSummary, + provider_target: &ProviderDescendantTarget, +) -> Option { + let season_number = provider_target.season_number; + match provider_id { + MetadataProviderId::Tmdb => Some(MetadataRefreshTarget { + item_id: season.id, + library_id: season.library_id, + provider_id, + item_type: season.item_type, + display_title: season.display_title, + relative_path: season.relative_path, + external_id: format!("tv:{show_external_id}:season:{season_number}"), + media_type: "tv_season".into(), + fetch_kind: MetadataRefreshFetchKind::TmdbShowSeason { + show_external_id: show_external_id.to_string(), + season_number, + }, + }), + MetadataProviderId::Tvdb => Some(MetadataRefreshTarget { + item_id: season.id, + library_id: season.library_id, + provider_id, + item_type: season.item_type, + display_title: season.display_title, + relative_path: season.relative_path, + external_id: format!( + "series:{show_external_id}:season:{}", + provider_target.season_external_id + ), + media_type: "season".into(), + fetch_kind: MetadataRefreshFetchKind::TvdbSeason { + show_external_id: show_external_id.to_string(), + season_number, + season_external_id: provider_target.season_external_id.clone(), + }, + }), + _ => None, + } +} + +fn metadata_refresh_target_for_show_episode( + provider_id: MetadataProviderId, + show_external_id: &str, + episode: MediaItemSummary, + provider_target: &ProviderDescendantTarget, +) -> Option { + let season_number = provider_target.season_number; + let episode_number = provider_target.episode_number; + match provider_id { + MetadataProviderId::Tmdb => Some(MetadataRefreshTarget { + item_id: episode.id, + library_id: episode.library_id, + provider_id, + item_type: episode.item_type, + display_title: episode.display_title, + relative_path: episode.relative_path, + external_id: format!( + "tv:{show_external_id}:season:{season_number}:episode:{episode_number}" + ), + media_type: "tv_episode".into(), + fetch_kind: MetadataRefreshFetchKind::TmdbShowEpisode { + show_external_id: show_external_id.to_string(), + season_number, + episode_number, + }, + }), + MetadataProviderId::Tvdb => Some(MetadataRefreshTarget { + item_id: episode.id, + library_id: episode.library_id, + provider_id, + item_type: episode.item_type, + display_title: episode.display_title, + relative_path: episode.relative_path, + external_id: format!( + "series:{show_external_id}:season:{season_number}:episode:{}", + provider_target.episode_external_id + ), + media_type: "episode".into(), + fetch_kind: MetadataRefreshFetchKind::TvdbEpisode { + show_external_id: show_external_id.to_string(), + season_number, + episode_number, + episode_external_id: provider_target.episode_external_id.clone(), + }, + }), + _ => None, + } +} + +async fn mark_metadata_refresh_target_pending( + db: &DbConn, + target: &MetadataRefreshTarget, +) -> Result { + db.run({ + let target = target.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + target.item_id, + target.provider_id, + &target.external_id, + Some(&target.media_type), + "pending", + None, + ) + } + }) + .await + .map_err(|error| { + log::error!( + "Failed to mark media item {} metadata refresh pending: {}", + target.item_id, + error + ); + Status::InternalServerError + }) +} + +async fn mark_metadata_refresh_targets_pending( + db: &DbConn, + targets: &[MetadataRefreshTarget], +) -> Result<(), Status> { + for target in targets { + mark_metadata_refresh_target_pending(db, target).await?; + } + + Ok(()) +} + +async fn record_metadata_refresh_error( + db: &DbConn, + target: &MetadataRefreshTarget, + message: &str, +) { + if let Err(error) = db + .run({ + let target = target.clone(); + let message = message.to_string(); + move |conn| { + set_item_metadata_refresh_state( + conn, + target.item_id, + target.provider_id, + &target.external_id, + Some(&target.media_type), + "error", + Some(&message), + ) + } + }) + .await + { + log::warn!( + "Failed to record metadata refresh error for media item {}: {}", + target.item_id, + error + ); + } +} + +async fn fetch_metadata_refresh_snapshots_for_language( + settings: &crate::config::Settings, + target: &MetadataRefreshTarget, + language: &str, +) -> Result { + let fetch_options = target.fetch_kind.snapshot_fetch_options(); + match &target.fetch_kind { + MetadataRefreshFetchKind::Direct => { + fetch_provider_metadata_snapshot_for_locale_with_options( + &settings.metadata, + target.provider_id.clone(), + &target.external_id, + &target.media_type, + language, + fetch_options, + ) + .await + } + MetadataRefreshFetchKind::TmdbShowSeason { + show_external_id, + season_number, + } => { + fetch_provider_season_metadata_snapshot_for_locale_with_options( + &settings.metadata, + target.provider_id.clone(), + show_external_id, + *season_number, + None, + language, + fetch_options, + ) + .await + } + MetadataRefreshFetchKind::TmdbShowEpisode { + show_external_id, + season_number, + episode_number, + } => { + fetch_provider_episode_metadata_snapshot_for_locale_with_options( + &settings.metadata, + target.provider_id.clone(), + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number: *season_number, + episode_number: *episode_number, + episode_external_id: None, + locale_key: language, + options: fetch_options, + }, + ) + .await + } + MetadataRefreshFetchKind::TvdbSeason { + show_external_id, + season_number, + season_external_id, + } => { + fetch_provider_season_metadata_snapshot_for_locale_with_options( + &settings.metadata, + target.provider_id.clone(), + show_external_id, + *season_number, + Some(season_external_id), + language, + fetch_options, + ) + .await + } + MetadataRefreshFetchKind::TvdbEpisode { + show_external_id, + season_number, + episode_number, + episode_external_id, + } => { + fetch_provider_episode_metadata_snapshot_for_locale_with_options( + &settings.metadata, + target.provider_id.clone(), + ProviderEpisodeMetadataSnapshotFetch { + show_external_id, + season_number: *season_number, + episode_number: *episode_number, + episode_external_id: Some(episode_external_id), + locale_key: language, + options: fetch_options, + }, + ) + .await + } + } +} + +async fn fetch_metadata_refresh_snapshots( + db: &DbConn, + settings: &crate::config::Settings, + target: &MetadataRefreshTarget, +) -> Result, String> { + let languages = load_refresh_target_metadata_languages(db, target.item_id).await?; + let languages = metadata_locales_for_provider(target.provider_id.clone(), &languages); + let mut snapshots = Vec::new(); + for language in languages { + snapshots.push( + fetch_metadata_refresh_snapshots_for_language(settings, target, &language).await?, + ); + } + Ok(snapshots) +} + +async fn execute_metadata_refresh_target( + db: &DbConn, + target: &MetadataRefreshTarget, + settings: &crate::config::Settings, +) -> bool { + if !begin_metadata_refresh_execution(target.item_id).await { + log::info!( + "Skipping duplicate {} metadata refresh for {}; another refresh for this item is \ + already running", + target.provider_id.as_storage_value(), + describe_metadata_refresh_target(target) + ); + return false; + } + + let failed = execute_metadata_refresh_target_inner(db, target, settings).await; + finish_metadata_refresh_execution(target.item_id).await; + failed +} + +async fn execute_metadata_refresh_target_inner( + db: &DbConn, + target: &MetadataRefreshTarget, + settings: &crate::config::Settings, +) -> bool { + log::info!( + "Starting {} metadata refresh for {} using target {} ({})", + target.provider_id.as_storage_value(), + describe_metadata_refresh_target(target), + target.external_id, + target.media_type + ); + let snapshot_result = fetch_metadata_refresh_snapshots(db, settings, target).await; + + match snapshot_result { + Ok(snapshots) => { + for snapshot in snapshots { + if let Err(status) = persist_snapshot_for_item( + db, + target.item_id, + &snapshot, + settings, + PersistSnapshotOptions::for_target(target), + ) + .await + { + let status_message = format!("{status:?}"); + log::warn!( + "Failed to persist refreshed {} metadata snapshot for {}: {}", + target.provider_id.as_storage_value(), + describe_metadata_refresh_target(target), + status_message + ); + record_metadata_refresh_error(db, target, &status_message).await; + return true; + } + } + + log::info!( + "Completed {} metadata refresh for {} using target {} ({})", + target.provider_id.as_storage_value(), + describe_metadata_refresh_target(target), + target.external_id, + target.media_type + ); + false + } + Err(error) => { + log::warn!( + "Failed to fetch refreshed {} metadata snapshot for {} using target {} ({}): {}", + target.provider_id.as_storage_value(), + describe_metadata_refresh_target(target), + target.external_id, + target.media_type, + error + ); + record_metadata_refresh_error(db, target, &error).await; + true + } + } +} + +async fn execute_metadata_refresh_targets( + db: &DbConn, + targets: &[MetadataRefreshTarget], + settings: &crate::config::Settings, +) { + for target in targets { + execute_metadata_refresh_target(db, target, settings).await; + } +} + +fn parse_tmdb_child_external_id(external_id: &str) -> Option<(&str, i32, Option)> { + let parts = external_id.split(':').collect::>(); + match parts.as_slice() { + [ + "tv", + show_external_id, + "season", + season_number, + ] => Some((*show_external_id, season_number.parse().ok()?, None)), + [ + "tv", + show_external_id, + "season", + season_number, + "episode", + episode_number, + ] => Some(( + *show_external_id, + season_number.parse().ok()?, + Some(episode_number.parse().ok()?), + )), + _ => None, + } +} + +fn parse_tvdb_child_external_id(external_id: &str) -> Option<(&str, Option, &str)> { + let parts = external_id.split(':').collect::>(); + match parts.as_slice() { + [ + "series", + show_external_id, + "season", + season_external_id, + ] => Some((*show_external_id, None, *season_external_id)), + [ + "series", + show_external_id, + "season", + season_number, + "episode", + episode_external_id, + ] => Some(( + *show_external_id, + season_number.parse().ok(), + *episode_external_id, + )), + _ => None, + } +} + +fn pending_metadata_refresh_target( + item: MediaItemSummary, + link: ItemMetadataLink, +) -> Option { + let provider_id = MetadataProviderId::from_storage_value(&link.provider_id)?; + let media_type = link.media_type.clone()?; + let fetch_kind = match provider_id { + MetadataProviderId::Tmdb => match media_type.as_str() { + "movie" | "tv" => MetadataRefreshFetchKind::Direct, + "tv_season" => { + let (show_external_id, season_number, _) = + parse_tmdb_child_external_id(&link.external_id)?; + MetadataRefreshFetchKind::TmdbShowSeason { + show_external_id: show_external_id.to_string(), + season_number, + } + } + "tv_episode" => { + let (show_external_id, season_number, episode_number) = + parse_tmdb_child_external_id(&link.external_id)?; + MetadataRefreshFetchKind::TmdbShowEpisode { + show_external_id: show_external_id.to_string(), + season_number, + episode_number: episode_number?, + } + } + _ => return None, + }, + MetadataProviderId::Tvdb => match media_type.as_str() { + "movie" | "series" => MetadataRefreshFetchKind::Direct, + "season" => { + let (show_external_id, _, season_external_id) = + parse_tvdb_child_external_id(&link.external_id)?; + MetadataRefreshFetchKind::TvdbSeason { + show_external_id: show_external_id.to_string(), + season_number: item.season_number.unwrap_or_default(), + season_external_id: season_external_id.to_string(), + } + } + "episode" => { + let (show_external_id, season_number, episode_external_id) = + parse_tvdb_child_external_id(&link.external_id)?; + MetadataRefreshFetchKind::TvdbEpisode { + show_external_id: show_external_id.to_string(), + season_number: season_number.or(item.season_number).unwrap_or_default(), + episode_number: item.episode_number.unwrap_or_default(), + episode_external_id: episode_external_id.to_string(), + } + } + _ => return None, + }, + _ => return None, + }; + + Some(MetadataRefreshTarget { + item_id: item.id, + library_id: item.library_id, + provider_id, + item_type: item.item_type, + display_title: item.display_title, + relative_path: item.relative_path, + external_id: link.external_id, + media_type, + fetch_kind, + }) +} + +pub(crate) async fn recover_pending_metadata_refreshes( + db: &DbConn, + settings: &crate::config::Settings, +) { + let links = match db.run(list_pending_item_metadata_links).await { + Ok(links) => links, + Err(error) => { + log::warn!("Failed to load pending metadata refresh links: {}", error); + return; + } + }; + if links.is_empty() { + return; + } + + execute_metadata_refresh_links( + db, + settings, + links, + "automatic_pending_recovery", + "Recover pending metadata refreshes", + ) + .await; +} + +async fn execute_metadata_refresh_links( + db: &DbConn, + settings: &crate::config::Settings, + links: Vec, + source: &str, + label: &str, +) { + let mut targets = Vec::new(); + for link in links { + let item_id = link.media_item_id; + let item = match db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + { + Ok(Some(item)) => item, + Ok(None) => continue, + Err(error) => { + log::warn!( + "Failed to load pending metadata item {}: {}", + item_id, + error + ); + continue; + } + }; + + if let Some(target) = pending_metadata_refresh_target(item, link) { + targets.push(target); + } + } + + let Some((activity_id, queued_targets)) = + register_metadata_refresh_activity("metadata", source, label.into(), None, None, targets) + .await + else { + return; + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(db, &queued_targets).await { + log::warn!( + "Failed to mark automatic metadata refresh targets pending: {:?}", + status + ); + cancel_metadata_refresh_activity(&activity_id).await; + return; + } + + mark_metadata_refresh_activity_running(&activity_id).await; + for target in queued_targets { + let failed = execute_metadata_refresh_target(db, &target, settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; +} + +async fn run_due_metadata_refreshes( + db: &DbConn, + settings: &crate::config::Settings, +) { + if settings.metadata.refresh_interval_days.is_none() { + return; + } + + let now = current_timestamp(); + let links = match db + .run(move |conn| list_due_item_metadata_links(conn, now, 8)) + .await + { + Ok(links) => links, + Err(error) => { + log::warn!("Failed to load due metadata refresh links: {}", error); + return; + } + }; + if links.is_empty() { + return; + } + + execute_metadata_refresh_links( + db, + settings, + links, + "automatic_interval_refresh", + "Refresh stale metadata", + ) + .await; +} + +/// Run scheduled metadata refresh work. +pub(crate) async fn run_scheduled_metadata_refreshes( + db: &DbConn, + settings: &crate::config::Settings, +) { + recover_pending_metadata_refreshes(db, settings).await; + run_due_metadata_refreshes(db, settings).await; +} + +async fn build_metadata_refresh_job( + db: &DbConn, + settings: &crate::config::Settings, + item: &MediaItemSummary, + provider_id: MetadataProviderId, + external_id: &str, + media_type: &str, +) -> Result { + let root = MetadataRefreshTarget { + item_id: item.id, + library_id: item.library_id, + provider_id, + item_type: item.item_type.clone(), + display_title: item.display_title.clone(), + relative_path: item.relative_path.clone(), + external_id: external_id.to_string(), + media_type: media_type.to_string(), + fetch_kind: MetadataRefreshFetchKind::Direct, + }; + let descendants = if item.item_type == "show" + && ((root.provider_id == MetadataProviderId::Tmdb && media_type == "tv") + || (root.provider_id == MetadataProviderId::Tvdb && media_type == "series")) + { + match load_show_descendant_refresh_targets( + db, + settings, + item.id, + root.provider_id.clone(), + external_id, + ) + .await + { + Ok(descendants) => descendants, + Err(status) => { + if status == Status::ServiceUnavailable { + log::warn!( + "Skipping descendant metadata refresh expansion for {} because {} is \ + unavailable; the root item will still be refreshed", + describe_metadata_refresh_target(&root), + root.provider_id.as_storage_value() + ); + Vec::new() + } else { + return Err(status); + } + } + } + } else { + Vec::new() + }; + + Ok(MetadataRefreshJob { root, descendants }) +} + +async fn load_metadata_summary_for_item( + db: &DbConn, + item_id: i32, + provider_id: MetadataProviderId, +) -> Result { + let summaries = db + .run(move |conn| get_item_metadata_summaries(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load current metadata summary for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + summaries + .into_iter() + .find(|summary| summary.provider_id == provider_id) + .ok_or(Status::NotFound) +} + +async fn load_snapshot_descendant_refresh_targets( + db: &DbConn, + item_id: i32, + snapshot: &StoredMetadataSnapshot, + settings: &crate::config::Settings, +) -> Result, Status> { + if (snapshot.provider_id == MetadataProviderId::Tmdb + && snapshot.media_type.as_deref() == Some("tv")) + || (snapshot.provider_id == MetadataProviderId::Tvdb + && snapshot.media_type.as_deref() == Some("series")) + { + Ok(load_show_descendant_refresh_targets( + db, + settings, + item_id, + snapshot.provider_id.clone(), + &snapshot.external_id, + ) + .await?) + } else { + Ok(Vec::new()) + } +} + +async fn fetch_snapshots_for_item_metadata_languages( + db: &DbConn, + settings: &crate::config::Settings, + item_id: i32, + provider_id: MetadataProviderId, + external_id: &str, + media_type: &str, + fetch_options: MetadataSnapshotFetchOptions, +) -> Result, Status> { + let languages = load_refresh_target_metadata_languages(db, item_id) + .await + .map_err(|error| { + log::error!( + "Failed to load metadata languages for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + let languages = metadata_locales_for_provider(provider_id.clone(), &languages); + let mut snapshots = Vec::new(); + for language in languages { + snapshots.push( + fetch_provider_metadata_snapshot_for_locale_with_options( + &settings.metadata, + provider_id.clone(), + external_id, + media_type, + &language, + fetch_options, + ) + .await + .map_err(|error| { + log::error!( + "Failed to fetch {} metadata snapshot for item {} locale {}: {}", + provider_id.as_storage_value(), + item_id, + language, + error + ); + Status::ServiceUnavailable + })?, + ); + } + Ok(snapshots) +} + +async fn persist_snapshot_tree_for_languages( + db: &DbConn, + item_id: i32, + snapshots: &[StoredMetadataSnapshot], + settings: &crate::config::Settings, + persist_options: PersistSnapshotOptions, +) -> Result { + let descendants = match snapshots.first() { + Some(snapshot) => { + load_snapshot_descendant_refresh_targets(db, item_id, snapshot, settings).await? + } + None => return Err(Status::ServiceUnavailable), + }; + if !descendants.is_empty() { + mark_metadata_refresh_targets_pending(db, &descendants).await?; + } + + let mut summary = None; + for snapshot in snapshots { + summary = Some( + persist_snapshot_for_item(db, item_id, snapshot, settings, persist_options).await?, + ); + } + if !descendants.is_empty() { + execute_metadata_refresh_targets(db, &descendants, settings).await; + } + summary.ok_or(Status::ServiceUnavailable) +} + +async fn run_library_metadata_people_refresh( + db: DbConn, + settings: Settings, + library_id: i32, + library_name: String, +) { + let targets = match db + .run(move |conn| list_metadata_people_for_library(conn, library_id)) + .await + { + Ok(targets) => targets, + Err(error) => { + log::warn!( + "Failed to load people for library {} metadata enrichment: {}", + library_id, + error + ); + return; + } + }; + if targets.is_empty() { + return; + } + + log::info!( + "Starting deferred people metadata refresh for library {} ({}) with {} person row(s)", + library_id, + library_name, + targets.len() + ); + + let mut failed = 0usize; + for target in targets { + if !refresh_metadata_person_details(&db, &settings, &target).await { + failed += 1; + } + } + + if failed == 0 { + log::info!( + "Completed deferred people metadata refresh for library {} ({})", + library_id, + library_name + ); + } else { + log::warn!( + "Completed deferred people metadata refresh for library {} ({}) with {} failure(s)", + library_id, + library_name, + failed + ); + } +} + +async fn refresh_metadata_person_details( + db: &DbConn, + settings: &Settings, + target: &MetadataPersonEnrichmentTarget, +) -> bool { + let mut details = match fetch_provider_person_metadata_for_locale( + &settings.metadata, + target.provider_id.clone(), + &target.external_id, + &target.locale_key, + ) + .await + { + Ok(Some(details)) => details, + Ok(None) => return true, + Err(error) => { + log::warn!( + "Failed to fetch {} person metadata for {} ({}): {}", + target.provider_id.as_storage_value(), + target.name, + target.external_id, + error + ); + return false; + } + }; + + cache_deferred_person_image(settings, target, &mut details).await; + + let person_id = target.id; + match db + .run(move |conn| update_metadata_person_details(conn, person_id, &details)) + .await + { + Ok(_) => true, + Err(error) => { + log::warn!( + "Failed to store {} person metadata for {} ({}): {}", + target.provider_id.as_storage_value(), + target.name, + target.external_id, + error + ); + false + } + } +} + +async fn cache_deferred_person_image( + settings: &Settings, + target: &MetadataPersonEnrichmentTarget, + details: &mut ProviderMetadataPerson, +) { + let Some(image_url) = details.image_url.as_deref() else { + return; + }; + let person_media_type = match &target.provider_id { + MetadataProviderId::Tvdb => "people", + _ => "person", + }; + let person_dir = managed_metadata_asset_dir( + &settings.general.data_dir, + target.provider_id.clone(), + &target.external_id, + Some(person_media_type), + &target.locale_key, + ); + let cache_key = format!("{}_profile", target.provider_id.as_storage_value()); + let Some(path) = try_cache_item_artwork(image_url, &person_dir, &cache_key).await else { + return; + }; + details.cached_image_path = Some(metadata_asset_db_path(&settings.general.data_dir, &path)); +} + +fn linked_shows_needing_descendant_backfill( + conn: &mut rocket_sync_db_pools::diesel::SqliteConnection +) -> Result, diesel::result::Error> { + let items = list_media_items(conn, None)?; + let mut pending = Vec::new(); + + for show in items.into_iter().filter(|item| item.item_type == "show") { + let Some(link) = get_primary_item_metadata_link(conn, show.id)? else { + continue; + }; + if link.provider_id != MetadataProviderId::Tmdb.as_storage_value() + || link.media_type.as_deref() != Some("tv") + { + continue; + } + + let seasons = list_media_item_children(conn, show.id)?; + let mut needs_backfill = false; + for season in seasons + .into_iter() + .filter(|item| item.item_type == "season") + { + if descendant_metadata_needs_backfill(get_primary_item_metadata_link(conn, season.id)?) + { + needs_backfill = true; + break; + } + + let episodes = list_media_item_children(conn, season.id)?; + if episodes.into_iter().any(|episode| { + episode.item_type == "episode" + && descendant_metadata_needs_backfill( + get_primary_item_metadata_link(conn, episode.id) + .ok() + .flatten(), + ) + }) { + needs_backfill = true; + break; + } + } + + if needs_backfill { + pending.push((show.id, link.external_id)); + } + } + + Ok(pending) +} + +fn descendant_metadata_needs_backfill(link: Option) -> bool { + match link { + None => true, + Some(link) => link.refresh_state == "error", + } +} + +async fn run_automatic_movie_metadata_linking( + db: &DbConn, + settings: &crate::config::Settings, + library_id: Option, + retry_previously_attempted: bool, + activity: Option<(String, Vec)>, +) { + let ready_providers = list_provider_statuses(&settings.metadata) + .into_iter() + .filter(|provider| provider.configured && provider.implemented) + .map(|provider| provider.id) + .collect::>(); + + let mut candidates = match db + .run(move |conn| { + if retry_previously_attempted { + list_automatic_metadata_refresh_candidates(conn, library_id, usize::MAX) + } else { + list_automatic_metadata_candidates(conn, library_id, 8) + } + }) + .await + { + Ok(candidates) => candidates, + Err(error) => { + log::warn!("Failed to load automatic metadata candidates: {}", error); + return; + } + }; + + let activity = if retry_previously_attempted { + if activity.is_some() { + activity + } else { + let item_ids = candidates + .iter() + .map(|candidate| candidate.item_id) + .collect::>(); + register_metadata_refresh_activity_for_items( + "library", + "manual_library_automatch", + "Match unlinked library metadata".into(), + library_id, + None, + None, + item_ids, + ) + .await + } + } else { + None + }; + if let Some((_, queued_item_ids)) = &activity { + let queued_item_ids = queued_item_ids + .iter() + .copied() + .collect::>(); + candidates.retain(|candidate| queued_item_ids.contains(&candidate.item_id)); + } + if let Some((activity_id, _)) = &activity { + mark_metadata_refresh_activity_running(activity_id).await; + } + + for candidate in candidates { + let mut failed = false; + let mut guessed_provider_id = None; + let mut guess = None; + for provider_id in candidate + .metadata_providers + .iter() + .filter(|provider_id| ready_providers.contains(provider_id)) + { + let guess_result = match candidate.library_kind { + crate::config::MediaLibraryKind::Shows => { + guess_provider_show_match( + &settings.metadata, + provider_id.clone(), + &candidate.relative_path, + &candidate.display_title, + ) + .await + } + _ => { + guess_provider_movie_match( + &settings.metadata, + provider_id.clone(), + &candidate.relative_path, + &candidate.display_title, + ) + .await + } + }; + match guess_result { + Ok(Some(result)) => { + guessed_provider_id = Some(provider_id.clone()); + guess = Some(result); + break; + } + Ok(None) => {} + Err(error) => { + log::warn!( + "Automatic {} match failed for item {} ({}): {}", + provider_id.as_storage_value(), + candidate.item_id, + candidate.relative_path, + error + ); + failed = true; + } + } + } + + if let (Some(provider_id), Some(result)) = (guessed_provider_id, guess) { + if let Err(error) = db + .run({ + let external_id = result.external_id.clone(); + let media_type = result.media_type.clone(); + let provider_id = provider_id.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + provider_id, + &external_id, + Some(&media_type), + "pending", + None, + ) + } + }) + .await + { + log::warn!( + "Failed to mark automatic metadata candidate {} pending: {}", + candidate.item_id, + error + ); + failed = true; + } + match fetch_snapshots_for_item_metadata_languages( + db, + settings, + candidate.item_id, + provider_id.clone(), + &result.external_id, + &result.media_type, + MetadataSnapshotFetchOptions::WITHOUT_PERSON_DETAILS, + ) + .await + { + Ok(snapshots) => { + if let Err(status) = persist_snapshot_tree_for_languages( + db, + candidate.item_id, + &snapshots, + settings, + PersistSnapshotOptions::WITHOUT_PERSON_ASSETS, + ) + .await + { + log::warn!( + "Failed to persist automatic metadata snapshot for item {}: {:?}", + candidate.item_id, + status + ); + if let Err(error) = db + .run({ + let external_id = result.external_id.clone(); + let media_type = Some(result.media_type.clone()); + let status_message = format!("{status:?}"); + let provider_id = provider_id.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + provider_id, + &external_id, + media_type.as_deref(), + "error", + Some(&status_message), + ) + } + }) + .await + { + log::warn!( + "Failed to record automatic metadata error for item {}: {}", + candidate.item_id, + error + ); + } + failed = true; + } + } + Err(error) => { + log::warn!( + "Failed to fetch automatic {} snapshot for item {}: {}", + provider_id.as_storage_value(), + candidate.item_id, + error + ); + if let Err(persist_error) = db + .run({ + let external_id = result.external_id.clone(); + let media_type = result.media_type.clone(); + let error_message = format!("{error:?}"); + let provider_id = provider_id.clone(); + move |conn| { + set_item_metadata_refresh_state( + conn, + candidate.item_id, + provider_id, + &external_id, + Some(&media_type), + "error", + Some(&error_message), + ) + } + }) + .await + { + log::warn!( + "Failed to record automatic metadata error for item {}: {}", + candidate.item_id, + persist_error + ); + } + failed = true; + } + } + } + + if candidate.library_kind != crate::config::MediaLibraryKind::Shows { + let attempted_at = current_timestamp(); + if let Err(error) = db + .run(move |conn| { + mark_metadata_match_attempted(conn, candidate.item_id, attempted_at) + }) + .await + { + log::warn!( + "Failed to record automatic metadata attempt for item {}: {}", + candidate.item_id, + error + ); + failed = true; + } + } + + if let Some((activity_id, _)) = &activity { + record_metadata_refresh_activity_progress(activity_id, failed).await; + } + } + + if let Some((activity_id, _)) = &activity { + complete_metadata_refresh_activity(activity_id).await; + } + + let pending_show_backfills = match db.run(linked_shows_needing_descendant_backfill).await { + Ok(items) => items, + Err(error) => { + log::warn!( + "Failed to load linked shows needing metadata backfill: {}", + error + ); + return; + } + }; + + for (show_item_id, external_id) in pending_show_backfills { + match load_show_descendant_refresh_targets( + db, + settings, + show_item_id, + MetadataProviderId::Tmdb, + &external_id, + ) + .await + { + Ok(targets) => { + if let Err(status) = mark_metadata_refresh_targets_pending(db, &targets).await { + log::warn!( + "Failed to mark descendant metadata pending for show item {}: {:?}", + show_item_id, + status + ); + } + execute_metadata_refresh_targets(db, &targets, settings).await; + } + Err(status) => { + log::warn!( + "Failed to backfill descendant metadata for show item {}: {:?}", + show_item_id, + status + ); + } + } + } + + if library_id.is_none() { + recover_pending_metadata_refreshes(db, settings).await; + run_due_metadata_refreshes(db, settings).await; + } +} + +fn current_user_id(user_guard: Option<&UserGuard>) -> Result, Status> { + user_guard + .map(|user_guard| { + user_guard + .claims() + .sub + .parse::() + .map_err(|_| Status::Unauthorized) + }) + .transpose() +} + +fn user_preferred_metadata_languages( + conn: &mut rocket_sync_db_pools::diesel::SqliteConnection, + user_id: Option, +) -> Result, diesel::result::Error> { + use crate::db::schema::users::dsl as users_dsl; + use diesel::{ + ExpressionMethods, + OptionalExtension, + QueryDsl, + RunQueryDsl, + }; + + let Some(user_id) = user_id else { + return Ok(vec![crate::metadata::DEFAULT_METADATA_LOCALE.to_string()]); + }; + + let stored = users_dsl::users + .filter(users_dsl::id.eq(user_id)) + .select(users_dsl::preferred_metadata_languages_json) + .first::(conn) + .optional()? + .unwrap_or_else(|| "[\"en-US\"]".into()); + + Ok(crate::web::routes::user::parse_preferred_metadata_languages(&stored)) +} + +async fn load_item_library_metadata_providers( + db: &DbConn, + library_id: i32, +) -> Result, Status> { + let providers = db + .run(move |conn| get_library_metadata_providers(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load library metadata providers for library {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + providers.ok_or(Status::NotFound) +} + +async fn load_item_library_metadata_languages( + db: &DbConn, + library_id: i32, +) -> Result, Status> { + let languages = db + .run(move |conn| get_library_metadata_languages(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load library metadata languages for library {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + Ok(languages.unwrap_or_else(|| vec![crate::metadata::DEFAULT_METADATA_LOCALE.to_string()])) +} + +async fn load_refresh_target_metadata_languages( + db: &DbConn, + item_id: i32, +) -> Result, String> { + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "media item was not found".to_string())?; + load_item_library_metadata_languages(db, item.library_id) + .await + .map_err(|status| format!("{status:?}")) +} + +async fn load_library_refresh_jobs( + db: &DbConn, + settings: &crate::config::Settings, + library_id: i32, +) -> Result, Status> { + let items = db + .run(move |conn| list_media_items(conn, Some(library_id))) + .await + .map_err(|error| { + log::error!( + "Failed to load media items for library {} metadata refresh: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + let mut jobs = Vec::new(); + for item in items + .into_iter() + .filter(|item| item.parent_id.is_none() && supports_manual_metadata_linking(item)) + { + let item_id = item.id; + let link = db + .run(move |conn| get_primary_item_metadata_link(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load linked metadata for media item {} library refresh: {}", + item_id, + error + ); + Status::InternalServerError + })?; + let Some(link) = link else { + continue; + }; + + let provider_id = + MetadataProviderId::from_storage_value(&link.provider_id).ok_or(Status::BadRequest)?; + let Some(media_type) = link.media_type.clone() else { + continue; + }; + jobs.push( + build_metadata_refresh_job( + db, + settings, + &item, + provider_id, + &link.external_id, + &media_type, + ) + .await?, + ); + } + + Ok(jobs) +} + +async fn load_library_summary( + db: &DbConn, + library_id: i32, +) -> Result { + let libraries = db + .run(get_persisted_library_summaries) + .await + .map_err(|error| { + log::error!("Failed to load media library summaries: {}", error); + Status::InternalServerError + })?; + + libraries + .into_iter() + .find(|library| library.id == library_id) + .ok_or(Status::NotFound) +} + +/// Return server bootstrap information for future browser and native clients. +#[openapi(tag = "Media")] +#[get("/api/v1/system/capabilities")] +pub async fn get_server_capabilities( + db: DbConn +) -> Result, Status> { + let settings = current_settings(); + let transcoding = inspect_transcoding_capability(&settings.ffmpeg); + let libraries_configured = db + .run(|conn| list_library_settings(conn).map(|libraries| libraries.len())) + .await + .map_err(|error| { + log::error!("Failed to count persisted libraries: {}", error); + Status::InternalServerError + })?; + + Ok(Json(ServerCapabilitiesResponse { + app_name: globals::GLOBAL_APP_NAME.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + server_url: globals::get_server_url(), + https_enabled: settings.server.use_https, + libraries_configured, + api_versions: vec!["v1".into()], + transcoding, + })) +} + +/// Trigger a full catalog scan and return the updated summary for one library. +#[openapi(tag = "Media")] +#[post("/api/v1/libraries//scan")] +pub async fn scan_library( + db: DbConn, + library_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let library_summary = load_library_summary(&db, library_id).await?; + if !begin_catalog_scan_execution() { + log::info!( + "Skipping duplicate manual library {} scan request; a catalog scan is already running", + library_id + ); + return Ok(Json(library_summary)); + } + + let ffmpeg_settings = settings.ffmpeg.clone(); + let library_name = library_summary.name.clone(); + let activity_id = register_library_scan_activity(library_id, &library_name).await; + tokio::spawn(async move { + mark_library_scan_activity_running(&activity_id).await; + log::info!( + "Starting manual media library catalog scan for library {} ({})", + library_id, + library_name + ); + let sync_result = db + .run(move |conn| { + sync_persisted_library_catalog_for_library(conn, &ffmpeg_settings, library_id) + }) + .await; + let failed = match sync_result { + Ok(Some(summary)) => { + log::info!( + "Completed manual media library catalog scan for library {} ({}): {} file(s), \ + status {:?}", + summary.id, + summary.name, + summary.total_files, + summary.status + ); + false + } + Ok(None) => { + log::warn!( + "Manual media library catalog scan requested missing library {}", + library_id + ); + true + } + Err(error) => { + log::error!( + "Failed to run manual library scan for library {}: {}", + library_id, + error + ); + true + } + }; + if failed { + log::warn!( + "Manual media library catalog scan did not complete successfully for library {}", + library_id + ); + } + complete_library_scan_activity(&activity_id, failed).await; + finish_catalog_scan_execution(); + }); + + Ok(Json(library_summary)) +} + +/// Delete items currently marked missing from one library's active catalog. +#[openapi(tag = "Media")] +#[delete("/api/v1/libraries//missing")] +pub async fn delete_library_missing_items( + db: DbConn, + library_id: i32, +) -> Result, Status> { + let exists = db + .run(move |conn| library_exists(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to inspect library {} before missing-item cleanup: {}", + library_id, + error + ); + Status::InternalServerError + })?; + if !exists { + return Err(Status::NotFound); + } + + let cleanup = db + .run(move |conn| delete_missing_media_items(conn, Some(library_id), None)) + .await + .map_err(|error| { + log::error!( + "Failed to delete missing media items for library {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + let library = load_library_summary(&db, library_id).await?; + + Ok(Json(MissingItemsCleanupResponse { + library_id, + deleted_files: cleanup.deleted_files, + deleted_items: cleanup.deleted_items, + removed_collection_items: cleanup.removed_collection_items, + library, + })) +} + +/// Return active backend activities such as metadata refresh work. +#[openapi(tag = "Media")] +#[get("/api/v1/system/activities")] +pub async fn get_system_activities() -> Json { + Json(SystemActivitiesResponse { + generated_at: current_timestamp(), + activities: current_system_activities().await, + }) +} + +/// Return metadata provider status for the current server configuration. +#[openapi(tag = "Media")] +#[get("/api/v1/metadata/providers")] +pub fn get_metadata_providers() -> Json> { + Json(list_provider_statuses(¤t_settings().metadata)) +} + +/// Return known locale keys and provider-specific mappings. +#[openapi(tag = "Media")] +#[get("/api/v1/metadata/locales")] +pub fn get_metadata_locales() -> Json> { + Json(vec![ + MetadataLocale { + key: "en-US".into(), + name: "English (United States)".into(), + tmdb: "en-US".into(), + tvdb: "eng".into(), + }, + MetadataLocale { + key: "en-GB".into(), + name: "English (United Kingdom)".into(), + tmdb: "en-GB".into(), + tvdb: "eng".into(), + }, + MetadataLocale { + key: "es-ES".into(), + name: "Spanish (Spain)".into(), + tmdb: "es-ES".into(), + tvdb: "spa".into(), + }, + MetadataLocale { + key: "fr-FR".into(), + name: "French (France)".into(), + tmdb: "fr-FR".into(), + tvdb: "fra".into(), + }, + MetadataLocale { + key: "de-DE".into(), + name: "German (Germany)".into(), + tmdb: "de-DE".into(), + tvdb: "deu".into(), + }, + MetadataLocale { + key: "it-IT".into(), + name: "Italian (Italy)".into(), + tmdb: "it-IT".into(), + tvdb: "ita".into(), + }, + MetadataLocale { + key: "ja-JP".into(), + name: "Japanese (Japan)".into(), + tmdb: "ja-JP".into(), + tvdb: "jpn".into(), + }, + MetadataLocale { + key: "pt-BR".into(), + name: "Portuguese (Brazil)".into(), + tmdb: "pt-BR".into(), + tvdb: "por".into(), + }, + ]) +} + +/// Return lightweight scan summaries for the configured media libraries. +#[openapi(tag = "Media")] +#[get("/api/v1/libraries")] +pub async fn get_libraries( + db: DbConn, + user_guard: Option, +) -> Result>, Status> { + let user_id = current_user_id(user_guard.as_ref())?; + + let libraries = db + .run(move |conn| { + let libraries = get_persisted_library_summaries(conn)?; + libraries + .into_iter() + .filter_map( + |library| match user_can_access_library(conn, library.id, user_id) { + Ok(true) => Some(Ok(library)), + Ok(false) => None, + Err(error) => Some(Err(error)), + }, + ) + .collect::, _>>() + }) + .await + .map_err(|error| { + log::error!("Failed to load media library summaries: {}", error); + Status::InternalServerError + })?; + + Ok(Json(libraries)) +} + +/// Return Kodi/Plex-style shelves for the browser home screen. +#[openapi(tag = "Media")] +#[get("/api/v1/home?")] +pub async fn get_home( + db: DbConn, + user_guard: Option, + library_id: Option, +) -> Result, Status> { + let user_id = current_user_id(user_guard.as_ref())?; + if let Some(library_id) = library_id { + let can_access = db + .run(move |conn| user_can_access_library(conn, library_id, user_id)) + .await + .map_err(|error| { + log::error!( + "Failed to check library access for {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + if !can_access { + return Err(Status::NotFound); + } + } + + let home = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + get_media_home_with_preferred_languages(conn, user_id, library_id, &languages) + }) + .await + .map_err(|error| { + log::error!("Failed to build media home shelves: {}", error); + Status::InternalServerError + })?; + + Ok(Json(home)) +} + +/// Return the persisted file inventory for a synchronized media library. +#[openapi(tag = "Media")] +#[get("/api/v1/libraries//files")] +pub async fn get_library_inventory( + db: DbConn, + library_id: i32, +) -> Result>, Status> { + let file_rows = db + .run(move |conn| get_library_files(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load media library inventory for id {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + if file_rows.is_empty() { + let exists = db + .run(move |conn| library_exists(conn, library_id)) + .await + .map_err(|error| { + log::error!( + "Failed to confirm media library existence for id {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + + if !exists { + return Err(Status::NotFound); + } + } + + Ok(Json(file_rows)) +} + +/// Return browser-facing media items, optionally filtered to one library. +#[openapi(tag = "Media")] +#[get("/api/v1/items?")] +pub async fn get_items( + db: DbConn, + user_guard: Option, + library_id: Option, +) -> Result>, Status> { + let user_id = current_user_id(user_guard.as_ref())?; + if let Some(library_id) = library_id { + let can_access = db + .run(move |conn| user_can_access_library(conn, library_id, user_id)) + .await + .map_err(|error| { + log::error!( + "Failed to check library access for {}: {}", + library_id, + error + ); + Status::InternalServerError + })?; + if !can_access { + return Err(Status::NotFound); + } + } + + let items = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + list_media_items_for_user_with_preferred_languages( + conn, user_id, library_id, &languages, + ) + }) + .await + .map_err(|error| { + log::error!("Failed to load media items: {}", error); + Status::InternalServerError + })?; + + Ok(Json(items)) +} + +/// Return details for one browser-facing media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items/")] +pub async fn get_item( + db: DbConn, + user_guard: Option, + item_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let data_dir = settings.general.data_dir.clone(); + let user_id = current_user_id(user_guard.as_ref())?; + + let item = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + get_media_item_with_preferred_languages(conn, item_id, &data_dir, &languages) + }) + .await + .map_err(|error| { + log::error!("Failed to load media item {}: {}", item_id, error); + Status::InternalServerError + })?; + + let mut item = item.ok_or(Status::NotFound)?; + let can_access = db + .run({ + let library_id = item.library_id; + move |conn| user_can_access_library(conn, library_id, user_id) + }) + .await + .map_err(|error| { + log::error!( + "Failed to check library access for item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + if !can_access { + return Err(Status::NotFound); + } + let item = db + .run(move |conn| { + apply_user_playback_context_to_detail(conn, user_id, &mut item)?; + Ok::<_, diesel::result::Error>(item) + }) + .await + .map_err(|error| { + log::error!( + "Failed to load playback context for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + Ok(Json(item)) +} + +/// Create a new playback session. +#[openapi(tag = "Media")] +#[post("/api/v1/sessions", format = "json", data = "")] +pub async fn create_session( + db: DbConn, + user_guard: Option, + request: Json, +) -> Result, Status> { + let payload = request.into_inner(); + let user_id = current_user_id(user_guard.as_ref()).unwrap_or(None); + let preferred_languages = db + .run(move |conn| user_preferred_metadata_languages(conn, user_id)) + .await + .map_err(|_| Status::InternalServerError)?; + + let decision = db + .run({ + let profile = payload.client_profile.clone(); + move |conn| get_playback_decision(conn, payload.item_id, Some(&profile)) + }) + .await + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + let audio_stream_index = db + .run({ + let preferred_languages = preferred_languages.clone(); + move |conn| preferred_audio_stream_index(conn, payload.item_id, &preferred_languages) + }) + .await + .map_err(|_| Status::InternalServerError)? + .filter(|index| *index > 0); + + let session_id = crate::transcode::next_session_id(); + + let session = crate::media::PlaybackSession { + session_id: session_id.clone(), + item_id: payload.item_id, + user_id, + client_profile: payload.client_profile, + decision, + created_at: current_timestamp(), + audio_stream_index, + }; + + ACTIVE_PLAYBACK_SESSIONS + .write() + .await + .insert(session_id, session.clone()); + + Ok(Json(session)) +} + +/// Delete a playback session. +#[openapi(tag = "Media")] +#[delete("/api/v1/sessions/")] +pub async fn delete_session(session_id: String) -> Status { + let removed = ACTIVE_PLAYBACK_SESSIONS.write().await.remove(&session_id); + + if removed.is_some() { + stop_active_transcode(&session_id).await; + + let settings = current_settings(); + let session_dir = PathBuf::from(&settings.general.data_dir) + .join("transcode_cache") + .join(&session_id); + + // Background cleanup + tokio::spawn(async move { + let _ = tokio::fs::remove_dir_all(session_dir).await; + }); + + Status::NoContent + } else { + Status::NotFound + } +} + +/// Stream content for a session (handles transcode or direct play). +#[rocket::get("/api/v1/sessions//stream?&")] +pub async fn get_session_stream( + db: DbConn, + range: RangeHeader, + session_id: String, + start_ms: Option, + audio_stream_index: Option, +) -> Result { + let session = ACTIVE_PLAYBACK_SESSIONS + .read() + .await + .get(&session_id) + .cloned() + .ok_or(Status::NotFound)?; + let selected_audio_stream_index = audio_stream_index.or(session.audio_stream_index); + + if session.decision.can_direct_play && selected_audio_stream_index.unwrap_or_default() == 0 { + stop_active_transcode(&session_id).await; + + let source_path = db + .run(move |conn| resolve_media_item_source_path(conn, session.item_id)) + .await + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + return open_ranged_file(source_path, &range) + .await + .map(SessionStream::File); + } + + let settings = current_settings(); + let session_dir = PathBuf::from(&settings.general.data_dir) + .join("transcode_cache") + .join(&session_id); + + // We'll write to an mp4 or matching container file + let container = session + .decision + .transcode_container + .clone() + .unwrap_or_else(|| "mp4".into()); + let output_path = session_dir.join(format!("output.{}", container)); + + let source_path = db + .run(move |conn| resolve_media_item_source_path(conn, session.item_id)) + .await + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + + let alternate_audio_stream_selected = selected_audio_stream_index.unwrap_or_default() > 0; + let video_codec = + if alternate_audio_stream_selected && session.decision.transcode_video_codec.is_none() { + Some("libx264".into()) + } else { + session.decision.transcode_video_codec.clone() + }; + let audio_codec = + if alternate_audio_stream_selected && session.decision.transcode_audio_codec.is_none() { + Some("aac".into()) + } else { + session.decision.transcode_audio_codec.clone() + }; + + let spec = crate::transcode::TranscodeSpec { + source_path, + output_path: output_path.clone(), + container, + video_codec, + audio_codec, + max_width: if session.client_profile.max_video_width > 0 { + Some(session.client_profile.max_video_width) + } else { + None + }, + max_height: if session.client_profile.max_video_height > 0 { + Some(session.client_profile.max_video_height) + } else { + None + }, + max_bitrate_kbps: if session.client_profile.max_bitrate_kbps > 0 { + Some(session.client_profile.max_bitrate_kbps) + } else { + None + }, + start_time_ms: start_ms.filter(|value| *value > 0), + audio_stream_index: selected_audio_stream_index, + }; + + stop_active_transcode(&session_id).await; + + match crate::transcode::spawn_transcode_stdout(&session_id, &spec, &settings.ffmpeg).await { + Ok(mut child) => { + let stdout = child.stdout.take().ok_or(Status::InternalServerError)?; + let stderr = child.stderr.take(); + let transcode_session_id = session_id.clone(); + let handle = tokio::spawn(async move { + let mut stderr_text = Vec::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_end(&mut stderr_text).await; + } + let status = child.wait().await; + if !stderr_text.is_empty() { + log::warn!( + "FFmpeg stderr: {}", + String::from_utf8_lossy(&stderr_text).trim() + ); + } + if let Ok(status) = status { + if !status.success() { + log::warn!("FFmpeg exited with status: {}", status); + } + } + }); + replace_active_transcode(transcode_session_id, handle).await; + + Ok(SessionStream::Transcode { + content_type: ContentType::MP4, + stdout, + }) + } + Err(e) => { + log::error!("Failed to spawn transcode: {}", e); + Err(Status::InternalServerError) + } + } +} + +/// Return direct-play versus transcode information for a media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//playback")] +pub async fn get_item_playback( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let decision = db + .run(move |conn| get_playback_decision(conn, item_id, None)) + .await + .map_err(|error| { + log::error!( + "Failed to build playback decision for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + decision.map(Json).ok_or(Status::NotFound) +} + +/// Serve a direct-play file stream for a browser-compatible media item. +#[get("/api/v1/items//stream")] +pub async fn stream_item( + db: DbConn, + range: RangeHeader, + item_id: i32, +) -> Result { + let decision = db + .run(move |conn| get_playback_decision(conn, item_id, None)) + .await + .map_err(|error| { + log::error!( + "Failed to build playback decision before streaming item {}: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + if !decision.can_direct_play { + return Err(Status::Conflict); + } + + let source_path = db + .run(move |conn| resolve_media_item_source_path(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to resolve stream source for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + open_ranged_file(source_path, &range).await +} + +/// Persist browser playback progress for a media item. +#[openapi(tag = "Media")] +#[post( + "/api/v1/items//progress", + format = "json", + data = "" +)] +pub async fn update_item_progress( + db: DbConn, + user_guard: UserGuard, + item_id: i32, + request: Json, +) -> Result { + let payload = request.into_inner(); + let user_id = current_user_id(Some(&user_guard))?.ok_or(Status::Unauthorized)?; + + db.run(move |conn| { + upsert_playback_progress( + conn, + user_id, + item_id, + payload.position_ms, + payload.duration_ms, + payload.completed, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to update playback progress for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + Ok(Status::Ok) +} + +/// Return stored metadata matches and provider readiness for one media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//metadata")] +pub async fn get_item_metadata( + db: DbConn, + user_guard: Option, + item_id: i32, +) -> Result, Status> { + let data_dir = current_settings().general.data_dir; + let user_id = current_user_id(user_guard.as_ref())?; + + let item_exists = db + .run(move |conn| get_media_item(conn, item_id, &data_dir)) + .await + .map_err(|error| { + log::error!( + "Failed to confirm media item {} before metadata load: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + if item_exists.is_none() { + return Err(Status::NotFound); + } + + let matches = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + let mut summaries = get_item_metadata_summaries(conn, item_id)?; + sort_item_metadata_summaries_for_languages(&mut summaries, &languages); + Ok::<_, diesel::result::Error>(summaries) + }) + .await + .map_err(|error| { + log::error!( + "Failed to load metadata matches for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + Ok(Json(ItemMetadataResponse { + item_id, + providers: list_provider_statuses(¤t_settings().metadata), + matches, + })) +} + +/// Return one normalized person and the media items they are credited on. +#[openapi(tag = "Media")] +#[get("/api/v1/people/")] +pub async fn get_person( + db: DbConn, + user_guard: Option, + person_id: i32, +) -> Result, Status> { + let user_id = current_user_id(user_guard.as_ref())?; + let response = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + let person = get_metadata_person_for_languages(conn, person_id, &languages)? + .ok_or(diesel::result::Error::NotFound)?; + let person_ids = get_metadata_person_locale_peer_ids(conn, person_id)?; + let mut credits = Vec::new(); + let mut seen_items = std::collections::HashSet::new(); + for credit in list_metadata_person_credit_summaries_for_person_ids(conn, &person_ids)? { + if !seen_items.insert(credit.media_item_id) { + continue; + } + if let Some((item, hierarchy)) = + crate::media::get_media_item_summary_with_hierarchy( + conn, + credit.media_item_id, + &languages, + )? + { + credits.push(MetadataPersonItemCredit { + credit, + item, + hierarchy, + }); + } + } + Ok::<_, diesel::result::Error>(MetadataPersonResponse { person, credits }) + }) + .await + .map_err(|error| match error { + diesel::result::Error::NotFound => Status::NotFound, + error => { + log::error!( + "Failed to load metadata person {} detail: {}", + person_id, + error + ); + Status::InternalServerError + } + })?; + + Ok(Json(response)) +} + +/// Serve a cached local person profile image. +#[get("/api/v1/people//image")] +pub async fn get_person_image( + db: DbConn, + user_guard: Option, + person_id: i32, +) -> Result { + let user_id = current_user_id(user_guard.as_ref())?; + let data_dir = current_settings().general.data_dir; + let image_path = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + let person = get_metadata_person_for_languages(conn, person_id, &languages)? + .ok_or(diesel::result::Error::NotFound)?; + Ok::<_, diesel::result::Error>(person.cached_image_path) + }) + .await + .map_err(|error| match error { + diesel::result::Error::NotFound => Status::NotFound, + error => { + log::error!( + "Failed to load metadata person {} image path: {}", + person_id, + error + ); + Status::InternalServerError + } + })? + .ok_or(Status::NotFound)?; + let image_path = resolve_metadata_asset_db_path(&data_dir, &image_path); + let expected_root = PathBuf::from(data_dir).join("metadata").join("people"); + if !image_path.starts_with(&expected_root) { + log::warn!( + "Refusing to serve metadata person image outside managed people cache: {:?}", + image_path + ); + return Err(Status::NotFound); + } + + NamedFile::open(image_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Search a configured provider for metadata candidates for a media item. +#[openapi(tag = "Media")] +#[get("/api/v1/items//metadata/search?&&&")] +pub async fn search_item_metadata( + db: DbConn, + item_id: i32, + query: Option, + providers: Option, + year: Option, + language: Option, +) -> Result>, Status> { + let settings = current_settings(); + let metadata_settings = settings.metadata.clone(); + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load media item summary {} for metadata search: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + + let library_providers = load_item_library_metadata_providers(&db, item.library_id).await?; + let requested_providers = parse_metadata_provider_selection(providers); + let providers = if requested_providers.is_empty() { + library_providers.clone() + } else { + requested_providers + }; + let fallback_query = item.display_title.clone(); + let search_title = query + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_query); + let effective_query = search_title.clone(); + let requested_language = language + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let library_languages = load_item_library_metadata_languages(&db, item.library_id).await?; + let search_language = requested_language + .or_else(|| library_languages.first().cloned()) + .unwrap_or_else(|| crate::metadata::DEFAULT_METADATA_LOCALE.to_string()); + let mut search_metadata_settings = metadata_settings.clone(); + let provider_statuses = list_provider_statuses(&metadata_settings) + .into_iter() + .map(|status| (status.id.clone(), status)) + .collect::>(); + + let mut results = Vec::new(); + let mut saw_provider = false; + let mut saw_success = false; + for provider_id in providers { + let Some(expected_media_type) = provider_search_media_type(provider_id.clone(), &item) + else { + continue; + }; + let Some(status) = provider_statuses.get(&provider_id) else { + continue; + }; + saw_provider = true; + if !library_providers.contains(&provider_id) || !status.configured || !status.implemented { + continue; + } + + if let Some(provider) = search_metadata_settings + .providers + .iter_mut() + .find(|provider| provider.id == provider_id) + { + provider.language = crate::metadata::provider_locale_key( + provider_id.clone(), + &normalize_locale_key(&search_language), + ); + } + + match search_provider( + &search_metadata_settings, + provider_id.clone(), + &effective_query, + Some(expected_media_type), + ) + .await + { + Ok(provider_results) => { + saw_success = true; + results.extend( + provider_results + .into_iter() + .filter(|result| result.media_type == expected_media_type), + ); + } + Err(error) => { + log::warn!( + "Metadata search failed for media item {} using provider {}: {}", + item_id, + provider_id.as_storage_value(), + error + ); + } + } + } + + if !saw_provider { + return Err(Status::NotFound); + } + if !saw_success && results.is_empty() { + return Err(Status::ServiceUnavailable); + } + + for result in &mut results { + result.score = Some(metadata_search_score(&search_title, year, result)); + } + results.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.title.cmp(&right.title)) + }); + + Ok(Json(results)) +} + +/// Link a media item to a provider match and queue the fetched metadata snapshot. +#[openapi(tag = "Media")] +#[post( + "/api/v1/items//metadata/link", + format = "json", + data = "" +)] +pub async fn link_item_metadata( + db: DbConn, + item_id: i32, + request: Json, +) -> Result, Status> { + let request = request.into_inner(); + let settings = current_settings(); + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load media item summary {} for metadata link: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + let library_providers = load_item_library_metadata_providers(&db, item.library_id).await?; + let provider_status = list_provider_statuses(&settings.metadata) + .into_iter() + .find(|status| status.id == request.provider_id) + .ok_or(Status::BadRequest)?; + if !library_providers.contains(&request.provider_id) + || !provider_status.configured + || !provider_status.implemented + { + return Err(Status::BadRequest); + } + if Some(request.media_type.as_str()) + != provider_search_media_type(request.provider_id.clone(), &item) + { + return Err(Status::BadRequest); + } + + let root_target = MetadataRefreshTarget { + item_id: item.id, + library_id: item.library_id, + provider_id: request.provider_id.clone(), + item_type: item.item_type.clone(), + display_title: item.display_title.clone(), + relative_path: item.relative_path.clone(), + external_id: request.external_id.clone(), + media_type: request.media_type.clone(), + fetch_kind: MetadataRefreshFetchKind::Direct, + }; + let Some((activity_id, queued_targets)) = register_metadata_refresh_activity( + "item", + "manual_item_link", + format!("Link metadata for {}", item.display_title), + Some(item.library_id), + Some(item.id), + vec![root_target.clone()], + ) + .await + else { + return Ok(Json( + load_metadata_summary_for_item(&db, item_id, request.provider_id.clone()).await?, + )); + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { + cancel_metadata_refresh_activity(&activity_id).await; + return Err(status); + } + + let pending_summary = + load_metadata_summary_for_item(&db, item_id, request.provider_id.clone()).await?; + tokio::spawn(async move { + mark_metadata_refresh_activity_running(&activity_id).await; + + let mut targets = queued_targets; + match build_metadata_refresh_job( + &db, + &settings, + &item, + root_target.provider_id.clone(), + &root_target.external_id, + &root_target.media_type, + ) + .await + { + Ok(refresh_job) => { + let expanded_targets = flatten_metadata_refresh_job(&refresh_job); + let additional_targets = + extend_metadata_refresh_activity(&activity_id, expanded_targets).await; + if !additional_targets.is_empty() { + if let Err(status) = + mark_metadata_refresh_targets_pending(&db, &additional_targets).await + { + log::warn!( + "Failed to mark manual metadata link descendants pending for item {}: \ + {:?}", + item_id, + status + ); + } + targets.extend(additional_targets); + } + } + Err(status) => { + log::warn!( + "Failed to expand manual metadata link refresh targets for item {}: {:?}", + item_id, + status + ); + } + } + + for target in targets { + let failed = execute_metadata_refresh_target(&db, &target, &settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; + }); + + Ok(Json(pending_summary)) +} + +/// Force-refresh the currently linked metadata snapshot for one media item. +#[openapi(tag = "Media")] +#[post("/api/v1/items//metadata/refresh")] +pub async fn refresh_item_metadata( + db: DbConn, + item_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let item = db + .run(move |conn| get_media_item_summary(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load media item summary {} for metadata refresh: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + if !supports_manual_metadata_linking(&item) { + return Err(Status::BadRequest); + } + + let link = db + .run(move |conn| get_preferred_item_metadata_link(conn, item_id)) + .await + .map_err(|error| { + log::error!( + "Failed to load linked metadata for media item {} refresh: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + let provider_id = + MetadataProviderId::from_storage_value(&link.provider_id).ok_or(Status::BadRequest)?; + let media_type = link.media_type.clone().ok_or(Status::BadRequest)?; + let external_id = link.external_id.clone(); + let refresh_job = build_metadata_refresh_job( + &db, + &settings, + &item, + provider_id.clone(), + &external_id, + &media_type, + ) + .await?; + let refresh_targets = flatten_metadata_refresh_job(&refresh_job); + let Some((activity_id, queued_targets)) = register_metadata_refresh_activity( + "item", + "manual_item_refresh", + format!("Refresh metadata for {}", item.display_title), + Some(item.library_id), + Some(item.id), + refresh_targets, + ) + .await + else { + return Ok(Json( + load_metadata_summary_for_item(&db, item_id, provider_id.clone()).await?, + )); + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { + cancel_metadata_refresh_activity(&activity_id).await; + return Err(status); + } + + let pending_summary = load_metadata_summary_for_item(&db, item_id, provider_id).await?; + tokio::spawn(async move { + mark_metadata_refresh_activity_running(&activity_id).await; + for target in queued_targets { + let failed = execute_metadata_refresh_target(&db, &target, &settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; + }); + + Ok(Json(pending_summary)) +} + +async fn run_manual_library_metadata_refresh( + db: DbConn, + settings: Settings, + library_id: i32, + library_name: String, +) -> (DbConn, Settings, String) { + let automatch_activity = register_manual_library_automatch_activity(&db, library_id).await; + let refresh_jobs = match load_library_refresh_jobs(&db, &settings, library_id).await { + Ok(refresh_jobs) => refresh_jobs, + Err(status) => { + log::warn!( + "Failed to prepare library {} metadata refresh jobs: {:?}", + library_id, + status + ); + if let Some((automatch_activity_id, _)) = &automatch_activity { + cancel_metadata_refresh_activity(automatch_activity_id).await; + } + return (db, settings, library_name); + } + }; + let refresh_targets = refresh_jobs + .iter() + .flat_map(flatten_metadata_refresh_job) + .collect::>(); + + let Some((activity_id, queued_targets)) = register_metadata_refresh_activity( + "library", + "manual_library_refresh", + format!("Refresh library metadata for {}", library_name), + Some(library_id), + None, + refresh_targets, + ) + .await + else { + run_automatic_movie_metadata_linking( + &db, + &settings, + Some(library_id), + true, + automatch_activity, + ) + .await; + return (db, settings, library_name); + }; + + if let Err(status) = mark_metadata_refresh_targets_pending(&db, &queued_targets).await { + cancel_metadata_refresh_activity(&activity_id).await; + if let Some((automatch_activity_id, _)) = &automatch_activity { + cancel_metadata_refresh_activity(automatch_activity_id).await; + } + log::warn!( + "Failed to mark library {} metadata refresh targets pending: {:?}", + library_id, + status + ); + return (db, settings, library_name); + } + + mark_metadata_refresh_activity_running(&activity_id).await; + for target in queued_targets { + let failed = execute_metadata_refresh_target(&db, &target, &settings).await; + record_metadata_refresh_activity_progress(&activity_id, failed).await; + } + complete_metadata_refresh_activity(&activity_id).await; + run_automatic_movie_metadata_linking( + &db, + &settings, + Some(library_id), + true, + automatch_activity, + ) + .await; + (db, settings, library_name) +} + +/// Force-refresh every linked metadata item within one library. +#[openapi(tag = "Media")] +#[post("/api/v1/libraries//metadata/refresh")] +pub async fn refresh_library_metadata( + db: DbConn, + library_id: i32, +) -> Result, Status> { + let settings = current_settings(); + let library_summary = load_library_summary(&db, library_id).await?; + let library_name = library_summary.name.clone(); + if !begin_library_metadata_refresh(library_id).await { + log::info!( + "Skipping duplicate library {} metadata refresh request; a refresh is already running", + library_id + ); + return Ok(Json(library_summary)); + } + + tokio::spawn(async move { + let refresh_task = tokio::spawn(run_manual_library_metadata_refresh( + db, + settings, + library_id, + library_name, + )); + let people_refresh = match refresh_task.await { + Ok(resources) => Some(resources), + Err(error) => { + log::error!( + "Library {} metadata refresh worker stopped unexpectedly: {}", + library_id, + error + ); + None + } + }; + finish_library_metadata_refresh(library_id).await; + if let Some((people_db, people_settings, people_library_name)) = people_refresh { + tokio::spawn(run_library_metadata_people_refresh( + people_db, + people_settings, + library_id, + people_library_name, + )); + } + }); + + Ok(Json(library_summary)) +} + +/// Serve poster or backdrop artwork for a linked media item, caching it locally on demand. +#[get("/api/v1/items//artwork?")] +pub async fn get_item_artwork( + db: DbConn, + user_guard: Option, + item_id: i32, + kind: Option, +) -> Result { + let artwork_kind = ArtworkKind::from_query_value(kind.as_deref()); + let user_id = current_user_id(user_guard.as_ref())?; + let data_dir = current_settings().general.data_dir; + let data_dir_for_local_resolve = data_dir.clone(); + + if artwork_kind != ArtworkKind::Logo { + if let Some(local_path) = db + .run(move |conn| { + resolve_local_item_artwork_path( + conn, + item_id, + artwork_kind, + &data_dir_for_local_resolve, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to resolve local artwork for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })? + { + return NamedFile::open(local_path) + .await + .map_err(|_| Status::NotFound); + } + } + + let link = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + get_preferred_item_artwork_metadata_link_for_languages( + conn, + item_id, + &languages, + artwork_kind, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to load linked metadata for media item {} artwork: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + let existing_cache = match artwork_kind { + ArtworkKind::Poster => link.cached_artwork_path.clone(), + ArtworkKind::Backdrop => link.cached_backdrop_path.clone(), + ArtworkKind::Logo => link.cached_logo_path.clone(), + }; + if let Some(existing_cache) = existing_cache { + let provider_id = MetadataProviderId::from_storage_value(&link.provider_id) + .unwrap_or(MetadataProviderId::Tmdb); + let expected_item_asset_dir = managed_metadata_asset_dir( + &data_dir, + provider_id.clone(), + &link.external_id, + link.media_type.as_deref(), + &link.locale_key, + ); + let existing_path = resolve_metadata_asset_db_path(&data_dir, &existing_cache); + let current_artwork_url = match artwork_kind { + ArtworkKind::Poster => link.artwork_url.as_deref(), + ArtworkKind::Backdrop => link.backdrop_url.as_deref(), + ArtworkKind::Logo => link.logo_url.as_deref(), + }; + let cache_key = match artwork_kind { + ArtworkKind::Poster => format!("{}_poster", provider_id.as_storage_value()), + ArtworkKind::Backdrop => format!("{}_backdrop", provider_id.as_storage_value()), + ArtworkKind::Logo => format!("{}_logo", provider_id.as_storage_value()), + }; + if let Some(url) = current_artwork_url { + let expected_path = + expected_artwork_cache_path(url, &expected_item_asset_dir, &cache_key); + if existing_path.is_file() && existing_path == expected_path { + return NamedFile::open(existing_path) + .await + .map_err(|_| Status::NotFound); + } + } + log::warn!( + "Ignoring stale cached artwork path for media item {}: {:?}", + item_id, + existing_path + ); + } + + let provider_id = MetadataProviderId::from_storage_value(&link.provider_id) + .unwrap_or(MetadataProviderId::Tmdb); + let item_dir = managed_metadata_asset_dir( + &data_dir, + provider_id.clone(), + &link.external_id, + link.media_type.as_deref(), + &link.locale_key, + ); + let current_artwork_url = match artwork_kind { + ArtworkKind::Poster => link.artwork_url.as_deref(), + ArtworkKind::Backdrop => link.backdrop_url.as_deref(), + ArtworkKind::Logo => link.logo_url.as_deref(), + } + .ok_or(Status::NotFound)?; + let cache_key = match artwork_kind { + ArtworkKind::Poster => format!("{}_poster", provider_id.as_storage_value()), + ArtworkKind::Backdrop => format!("{}_backdrop", provider_id.as_storage_value()), + ArtworkKind::Logo => format!("{}_logo", provider_id.as_storage_value()), + }; + let cached_path = try_cache_item_artwork(current_artwork_url, &item_dir, &cache_key) + .await + .ok_or(Status::BadGateway)?; + + let link_id = link.id; + let stored_path = cached_path.clone(); + let data_dir_for_update = data_dir.clone(); + db.run(move |conn| { + update_cached_artwork_path( + conn, + link_id, + artwork_kind, + &stored_path, + &data_dir_for_update, + ) + }) + .await + .map_err(|error| { + log::error!( + "Failed to persist cached artwork path for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })?; + + NamedFile::open(cached_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Serve a discovered theme-song asset for a media item. +#[get("/api/v1/items//theme")] +pub async fn get_item_theme( + db: DbConn, + item_id: i32, +) -> Result { + let data_dir = current_settings().general.data_dir; + let theme_path = db + .run(move |conn| resolve_item_theme_song_path(conn, item_id, &data_dir)) + .await + .map_err(|error| { + log::error!( + "Failed to resolve theme song for media item {}: {}", + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + NamedFile::open(theme_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Serve a discovered subtitle sidecar for a media item. +#[get("/api/v1/items//subtitles/")] +pub async fn get_item_subtitle( + db: DbConn, + item_id: i32, + track_index: usize, +) -> Result { + let data_dir = current_settings().general.data_dir; + let subtitle_path = db + .run(move |conn| resolve_item_subtitle_path(conn, item_id, track_index, &data_dir)) + .await + .map_err(|error| { + log::error!( + "Failed to resolve subtitle track {} for media item {}: {}", + track_index, + item_id, + error + ); + Status::InternalServerError + })? + .ok_or(Status::NotFound)?; + + NamedFile::open(subtitle_path) + .await + .map_err(|_| Status::NotFound) +} + +/// Search browser-facing media items and metadata entities. +#[openapi(tag = "Media")] +#[get("/api/v1/search?")] +pub async fn search_items( + db: DbConn, + user_guard: Option, + query: Option<&str>, +) -> Result>, Status> { + let query = query.unwrap_or_default().to_string(); + let user_id = current_user_id(user_guard.as_ref())?; + let results = db + .run(move |conn| { + let languages = user_preferred_metadata_languages(conn, user_id)?; + let normalized_query = query.trim().to_ascii_lowercase(); + let mut results = search_media_items_for_user_with_preferred_languages( + conn, user_id, &query, None, &languages, + )? + .into_iter() + .map(|item| MediaSearchResult::Item { item }) + .collect::>(); + if !normalized_query.is_empty() { + results.extend( + list_metadata_collection_summaries_with_preferred_languages( + conn, + None, + &languages, + &[], + )? + .into_iter() + .filter(|collection| collection_matches_query(collection, &normalized_query)) + .map(|collection| MediaSearchResult::Collection { collection }), + ); + results.extend( + search_metadata_people_with_preferred_languages(conn, &query, &languages)? + .into_iter() + .map(|person| MediaSearchResult::Person { person }), + ); + } + Ok::<_, diesel::result::Error>(results) + }) + .await + .map_err(|error| { + log::error!("Failed to search media items: {}", error); + Status::InternalServerError + })?; + + Ok(Json(results)) +} + +fn collection_matches_query( + collection: &MetadataCollectionSummary, + query: &str, +) -> bool { + collection.name.to_ascii_lowercase().contains(query) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn search_result( + provider_id: MetadataProviderId, + title: &str, + ) -> MetadataSearchResult { + MetadataSearchResult { + provider_id, + external_id: "1".into(), + media_type: "movie".into(), + title: title.into(), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + score: None, + } + } + + #[test] + fn metadata_search_score_penalizes_tmdb_case_mismatch() { + let exact = search_result(MetadataProviderId::Tmdb, "The Matrix"); + let mismatched_case = search_result(MetadataProviderId::Tmdb, "THE MATRIX"); + + assert!( + metadata_search_score("The Matrix", None, &mismatched_case) + < metadata_search_score("The Matrix", None, &exact) + ); + } + + #[test] + fn metadata_search_score_penalizes_case_mismatch_after_year_bonus() { + let mismatched_case = MetadataSearchResult { + release_year: Some(2021), + ..search_result(MetadataProviderId::Tmdb, "Free Guy") + }; + + assert!(metadata_search_score("free guy", Some(2021), &mismatched_case) < 1.0); + } + + #[test] + fn metadata_search_score_penalizes_tvdb_case_mismatch() { + let exact = search_result(MetadataProviderId::Tvdb, "The Matrix"); + let mismatched_case = search_result(MetadataProviderId::Tvdb, "THE MATRIX"); + + assert!( + metadata_search_score("The Matrix", None, &mismatched_case) + < metadata_search_score("The Matrix", None, &exact) + ); + } + + #[test] + fn metadata_search_score_keeps_case_penalty_provider_scoped() { + let tmdb_mismatched_case = search_result(MetadataProviderId::Tmdb, "THE MATRIX"); + let musicbrainz_mismatched_case = + search_result(MetadataProviderId::MusicBrainz, "THE MATRIX"); + + assert!( + metadata_search_score("The Matrix", None, &tmdb_mismatched_case) + < metadata_search_score("The Matrix", None, &musicbrainz_mismatched_case) + ); + } + + #[test] + fn collection_search_matches_title_only() { + let collection = MetadataCollectionSummary { + id: "collection:tmdb:1".into(), + provider_id: MetadataProviderId::Tmdb, + external_id: "james-bond-external-id".into(), + name: "James Bond Collection".into(), + overview: Some("A spy franchise overview.".into()), + artwork_url: None, + backdrop_url: None, + theme_song_url: None, + item_ids: vec![1], + item_count: 1, + }; + + assert!(collection_matches_query(&collection, "james bond")); + assert!(!collection_matches_query(&collection, "spy")); + assert!(!collection_matches_query(&collection, "external")); + } +} diff --git a/crates/server/src/web/routes/mod.rs b/crates/server/src/web/routes/mod.rs index 636ffe42..78d04c70 100644 --- a/crates/server/src/web/routes/mod.rs +++ b/crates/server/src/web/routes/mod.rs @@ -4,20 +4,68 @@ pub mod auth; pub mod common; pub mod dependencies; +pub mod media; +pub mod settings; pub mod user; // lib imports +use rocket::routes; use rocket_okapi::openapi_get_routes; // this is a replacement for the rocket::routes macro -pub fn all_routes() -> Vec { +pub fn api_routes() -> Vec { openapi_get_routes![ - common::index, auth::login, auth::logout, auth::jwt_test, auth::admin_test, auth::user_info, dependencies::get_dependencies, + media::get_server_capabilities, + media::get_system_activities, + media::get_metadata_providers, + media::get_metadata_locales, + media::get_home, + media::get_libraries, + media::scan_library, + media::delete_library_missing_items, + media::refresh_library_metadata, + media::get_library_inventory, + media::get_items, + media::get_item, + media::get_item_metadata, + media::get_person, + media::search_item_metadata, + media::link_item_metadata, + media::refresh_item_metadata, + media::get_item_playback, + media::create_session, + media::delete_session, + media::search_items, + media::update_item_progress, + settings::get_settings, + settings::get_logs, + settings::clear_metadata_cache, + settings::run_scheduled_task, + settings::update_settings, + settings::add_library, + settings::remove_library, + user::get_bootstrap, + user::list_users, + user::update_user, user::create_user, ] } + +pub fn spa_routes() -> Vec { + routes![ + common::index, + common::spa_asset, + user::get_user_profile_image, + media::get_item_artwork, + media::get_person_image, + media::get_item_theme, + media::get_item_subtitle, + media::stream_item, + media::get_session_stream + ] +} diff --git a/crates/server/src/web/routes/settings.rs b/crates/server/src/web/routes/settings.rs new file mode 100644 index 00000000..2a1db66a --- /dev/null +++ b/crates/server/src/web/routes/settings.rs @@ -0,0 +1,471 @@ +//! Settings and library-management routes. + +// lib imports +use chrono::{ + DateTime, + Local, + LocalResult, + NaiveDate, + NaiveDateTime, + TimeZone, +}; +use once_cell::sync::Lazy; +use regex::Regex; +use rocket::delete; +use rocket::get; +use rocket::http::Status; +use rocket::post; +use rocket::put; +use rocket::serde::json::Json; +use rocket_okapi::openapi; +use schemars::JsonSchema; +use serde::{ + Deserialize, + Serialize, +}; + +// local imports +use crate::config::{ + MediaLibrarySettings, + Settings, + current_settings, + merge_metadata_provider_secret_state, + replace_current_settings, + save_database_settings, + save_settings, + settings_file_path, + settings_for_api_response, + settings_with_persisted_secrets, +}; +use crate::db::DbConn; +use crate::globals; +use crate::logging::{ + normalize_display_path, + normalize_log_source_path, +}; +use crate::media::{ + add_library_setting, + count_persisted_libraries, + list_library_settings, + remove_library_setting, + replace_library_settings, +}; + +static STRUCTURED_LOG_LINE_REGEX: Lazy = Lazy::new(|| { + Regex::new(concat!( + r"^(?P\S+) \[(?P[^]]+)\] \[(?P[^]]+)\] ", + r"\[(?P[^]]+)\] (?P.*)$", + )) + .expect("Failed to compile structured log regex") +}); + +/// Settings response payload. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SettingsResponse { + /// Current settings snapshot. + pub settings: Settings, + /// Path to the YAML settings file. + pub settings_path: String, +} + +/// Metadata cache clear response. +#[derive(Debug, Serialize, JsonSchema)] +pub struct MetadataCacheClearResponse { + /// Number of cache files removed. + pub removed_files: usize, +} + +/// Scheduled task manual run response. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ScheduledTaskRunResponse { + /// Scheduled task identifier. + pub task_id: String, + /// Whether the task was accepted for background execution. + pub started: bool, + /// Human-readable status message. + pub message: String, +} + +/// Add-library request payload. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AddLibraryRequest { + /// New library configuration. + pub library: MediaLibrarySettings, +} + +/// One structured log entry parsed from the application log file. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct LogEntry { + /// Original timestamp string from the log file. + pub timestamp: String, + /// Log level such as `INFO` or `WARN`. + pub level: String, + /// Module path emitted by the logger. + pub module: String, + /// Source file path for the log entry. + pub source_file_path: String, + /// Source line number, when available. + pub line_number: Option, + /// Human-readable log message. + pub message: String, +} + +/// Structured log response for the settings page. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct LogEntriesResponse { + /// Path to the active application log file. + pub log_path: String, + /// Parsed log entries matching the request filters. + pub entries: Vec, +} + +fn merged_settings_response( + settings: Settings, + libraries: Vec, +) -> SettingsResponse { + let mut merged = settings_for_api_response(&settings); + merged.media.libraries = libraries; + SettingsResponse { + settings: merged, + settings_path: normalize_display_path(&settings_file_path().to_string_lossy()), + } +} + +fn persist_bootstrap_settings(settings: &Settings) -> Result<(), Status> { + save_settings(settings).map_err(|error| { + log::error!("Failed to save settings: {}", error); + Status::InternalServerError + }) +} + +fn parse_log_source(source: &str) -> (String, Option) { + let trimmed = normalize_log_source_path(source); + let Some((path, line)) = trimmed.rsplit_once(':') else { + return (trimmed.to_string(), None); + }; + + (path.to_string(), line.trim().parse::().ok()) +} + +fn parse_log_entry_timestamp(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value.trim()).ok() +} + +fn parse_log_filter_timestamp(value: Option<&str>) -> Option> { + let value = value?.trim(); + if value.is_empty() { + return None; + } + + if let Ok(parsed) = DateTime::parse_from_rfc3339(value) { + return Some(parsed); + } + + for format in [ + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d", + ] { + if format == "%Y-%m-%d" { + if let Ok(parsed) = NaiveDate::parse_from_str(value, format) { + let Some(naive) = parsed.and_hms_opt(0, 0, 0) else { + continue; + }; + return match Local.from_local_datetime(&naive) { + LocalResult::Single(date_time) => Some(date_time.fixed_offset()), + LocalResult::Ambiguous(date_time, _) => Some(date_time.fixed_offset()), + LocalResult::None => None, + }; + } + continue; + } + + if let Ok(parsed) = NaiveDateTime::parse_from_str(value, format) { + return match Local.from_local_datetime(&parsed) { + LocalResult::Single(date_time) => Some(date_time.fixed_offset()), + LocalResult::Ambiguous(date_time, _) => Some(date_time.fixed_offset()), + LocalResult::None => None, + }; + } + } + + None +} + +fn read_structured_log_entries( + level: Option<&str>, + module: Option<&str>, + search: Option<&str>, + since: Option<&str>, + until: Option<&str>, + limit: usize, +) -> Vec { + let contents = std::fs::read_to_string(&globals::APP_PATHS.log_path).unwrap_or_default(); + let level_filter = level + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); + let module_filter = module + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); + let search_filter = search + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); + let since_filter = parse_log_filter_timestamp(since); + let until_filter = parse_log_filter_timestamp(until); + + let mut entries = contents + .lines() + .filter_map(|line| { + let captures = STRUCTURED_LOG_LINE_REGEX.captures(line)?; + let timestamp = captures.name("timestamp")?.as_str().to_string(); + let level = captures.name("level")?.as_str().to_string(); + let module_name = captures.name("module")?.as_str().to_string(); + let source = captures.name("source")?.as_str().to_string(); + let message = captures.name("message")?.as_str().to_string(); + let (source_file_path, line_number) = parse_log_source(&source); + + Some(LogEntry { + timestamp, + level, + module: module_name, + source_file_path, + line_number, + message, + }) + }) + .filter(|entry| { + let level_matches = level_filter + .as_ref() + .map(|filter| entry.level.to_ascii_lowercase() == *filter) + .unwrap_or(true); + let module_matches = module_filter + .as_ref() + .map(|filter| entry.module.to_ascii_lowercase().contains(filter)) + .unwrap_or(true); + let search_matches = search_filter + .as_ref() + .map(|filter| { + entry.message.to_ascii_lowercase().contains(filter) + || entry.module.to_ascii_lowercase().contains(filter) + || entry.source_file_path.to_ascii_lowercase().contains(filter) + }) + .unwrap_or(true); + let timestamp_matches = parse_log_entry_timestamp(&entry.timestamp) + .map(|timestamp| { + let after_since = since_filter + .as_ref() + .map(|filter| timestamp >= *filter) + .unwrap_or(true); + let before_until = until_filter + .as_ref() + .map(|filter| timestamp <= *filter) + .unwrap_or(true); + after_since && before_until + }) + .unwrap_or(since_filter.is_none() && until_filter.is_none()); + level_matches && module_matches && search_matches && timestamp_matches + }) + .collect::>(); + + entries.reverse(); + entries.truncate(limit); + entries +} + +/// Return the current server settings snapshot. +#[openapi(tag = "Settings")] +#[get("/api/v1/settings")] +pub async fn get_settings(db: DbConn) -> Result, Status> { + let settings = current_settings(); + let libraries = db.run(list_library_settings).await.map_err(|error| { + log::error!("Failed to load persisted library settings: {}", error); + Status::InternalServerError + })?; + + persist_bootstrap_settings(&settings)?; + + Ok(Json(merged_settings_response(settings, libraries))) +} + +/// Clear cached provider metadata responses. +#[openapi(tag = "Settings")] +#[post("/api/v1/settings/metadata-cache/clear")] +pub fn clear_metadata_cache() -> Result, Status> { + let data_dir = current_settings().general.data_dir; + let removed_files = + crate::metadata::clear_metadata_response_cache(&data_dir).map_err(|error| { + log::error!("Failed to clear metadata response cache: {}", error); + Status::InternalServerError + })?; + Ok(Json(MetadataCacheClearResponse { removed_files })) +} + +/// Start one scheduled task immediately. +#[openapi(tag = "Settings")] +#[post("/api/v1/scheduled-tasks//run")] +pub fn run_scheduled_task( + db: DbConn, + task_id: &str, +) -> Result, Status> { + let message = match task_id { + "metadata_refresh" => { + crate::scheduled_tasks::start_metadata_refresh_task(db); + "Metadata refresh started" + } + "trash_cleanup" => { + crate::scheduled_tasks::start_trash_cleanup_task(db); + "Trash cleanup started" + } + "database_maintenance" => { + crate::scheduled_tasks::start_database_maintenance_task(db); + "Database maintenance started" + } + _ => return Err(Status::NotFound), + }; + + Ok(Json(ScheduledTaskRunResponse { + task_id: task_id.to_string(), + started: true, + message: message.to_string(), + })) +} + +/// Return structured application logs for the settings page. +#[openapi(tag = "Settings")] +#[get("/api/v1/settings/logs?&&&&&")] +pub fn get_logs( + level: Option<&str>, + module: Option<&str>, + search: Option<&str>, + since: Option<&str>, + until: Option<&str>, + limit: Option, +) -> Json { + Json(LogEntriesResponse { + log_path: normalize_display_path(&globals::APP_PATHS.log_path), + entries: read_structured_log_entries( + level, + module, + search, + since, + until, + limit.unwrap_or(200).clamp(1, 500), + ), + }) +} + +/// Replace the full settings snapshot and persist it to disk. +#[openapi(tag = "Settings")] +#[put("/api/v1/settings", format = "json", data = "")] +pub async fn update_settings( + db: DbConn, + settings: Json, +) -> Result, Status> { + let mut settings = settings.into_inner(); + let existing_settings = current_settings(); + merge_metadata_provider_secret_state(&mut settings, &existing_settings); + let settings_for_database = settings_with_persisted_secrets(&settings).map_err(|error| { + log::error!("Failed to persist provider credentials: {}", error); + Status::InternalServerError + })?; + let libraries = settings_for_database.media.libraries.clone(); + let settings_for_database_for_db = settings_for_database.clone(); + let persisted_libraries = db + .run(move |conn| { + let existing_count = + count_persisted_libraries(conn).map_err(|error| error.to_string())? as usize; + let persisted_libraries = if libraries.len() < existing_count { + log::warn!( + "Preserving {} persisted media libraries omitted from settings update; use \ + the library delete route to remove libraries", + existing_count - libraries.len() + ); + let mut merged = list_library_settings(conn).map_err(|error| error.to_string())?; + for (index, library) in libraries.iter().cloned().enumerate() { + if let Some(existing) = merged.get_mut(index) { + *existing = library; + } + } + replace_library_settings(conn, &merged).map_err(|error| error.to_string())? + } else { + replace_library_settings(conn, &libraries).map_err(|error| error.to_string())? + }; + save_database_settings(conn, &settings_for_database_for_db)?; + Ok::<_, String>(persisted_libraries) + }) + .await + .map_err(|error| { + log::error!("Failed to replace persisted library settings: {}", error); + Status::InternalServerError + })?; + + persist_bootstrap_settings(&settings_for_database)?; + let mut runtime_settings = settings_for_database.clone(); + runtime_settings.media.libraries.clear(); + replace_current_settings(runtime_settings); + + Ok(Json(merged_settings_response( + settings_for_database, + persisted_libraries, + ))) +} + +/// Append a new library to the persisted media-library settings. +#[openapi(tag = "Settings")] +#[post("/api/v1/settings/libraries", format = "json", data = "")] +pub async fn add_library( + db: DbConn, + request: Json, +) -> Result, Status> { + let mut library = request.into_inner().library; + library.normalize(); + + let libraries = db + .run(move |conn| add_library_setting(conn, &library)) + .await + .map_err(|error| { + log::error!("Failed to add persisted library setting: {}", error); + Status::InternalServerError + })?; + + let settings = current_settings(); + persist_bootstrap_settings(&settings)?; + + Ok(Json(merged_settings_response(settings, libraries))) +} + +/// Remove one configured library from the database and return the merged settings snapshot. +#[openapi(tag = "Settings")] +#[delete("/api/v1/settings/libraries/")] +pub async fn remove_library( + db: DbConn, + library_index: usize, +) -> Result, Status> { + let removed = db + .run(move |conn| remove_library_setting(conn, library_index)) + .await + .map_err(|error| { + log::error!( + "Failed to remove persisted library at index {}: {}", + library_index, + error + ); + Status::InternalServerError + })?; + if !removed { + return Err(Status::NotFound); + } + + let settings = current_settings(); + let libraries = db.run(list_library_settings).await.map_err(|error| { + log::error!( + "Failed to reload persisted libraries after removal: {}", + error + ); + Status::InternalServerError + })?; + + persist_bootstrap_settings(&settings)?; + + Ok(Json(merged_settings_response(settings, libraries))) +} diff --git a/crates/server/src/web/routes/user.rs b/crates/server/src/web/routes/user.rs index fd4d75ac..7bc26a03 100644 --- a/crates/server/src/web/routes/user.rs +++ b/crates/server/src/web/routes/user.rs @@ -1,15 +1,58 @@ // lib imports -use diesel::{QueryDsl, RunQueryDsl}; +use std::path::{ + Path, + PathBuf, +}; +use std::sync::atomic::Ordering; +use std::time::{ + SystemTime, + UNIX_EPOCH, +}; + +use base64::{ + Engine as _, + engine::general_purpose, +}; +use diesel::{ + ExpressionMethods, + OptionalExtension, + QueryDsl, + RunQueryDsl, + SelectableHelper, +}; +use rocket::fs::NamedFile; +use rocket::get; use rocket::http::Status; use rocket::post; -use rocket::serde::{Deserialize, json::Json}; +use rocket::put; +use rocket::serde::{ + Deserialize, + Serialize, + json::Json, +}; +use rocket::tokio::fs; use rocket_okapi::JsonSchema; use rocket_okapi::openapi; +use sha2::{ + Digest, + Sha256, +}; // local imports -use crate::auth::AdminGuard; +use crate::auth::{ + AdminGuard, + UserGuard, +}; +use crate::config::current_settings; use crate::db::DbConn; use crate::db::models::User; +use crate::globals::{ + CURRENT_ENV, + Environment, +}; + +const PROFILE_IMAGE_MAX_BYTES: usize = 2 * 1024 * 1024; +const PROFILE_IMAGE_ROUTE_PREFIX: &str = "/api/v1/user-profile-images/"; #[derive(Deserialize, JsonSchema)] pub struct CreateUserForm { @@ -17,6 +60,221 @@ pub struct CreateUserForm { pub password: String, pub pin: Option, pub admin: bool, + pub birthday: Option, + pub profile_image_upload: Option, + pub preferred_metadata_languages: Option>, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateUserForm { + pub username: String, + pub admin: bool, + pub birthday: Option, + pub profile_image_upload: Option, + pub remove_profile_image: Option, + pub preferred_metadata_languages: Option>, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ProfileImageUploadForm { + pub mime_type: String, + pub data_base64: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct UserSummary { + pub id: i32, + pub username: String, + pub admin: bool, + pub birthday: Option, + pub profile_image_url: Option, + pub preferred_metadata_languages: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct BootstrapResponse { + pub has_users: bool, + pub current_user: Option, +} + +#[openapi(tag = "Users")] +#[get("/api/v1/bootstrap")] +pub async fn get_bootstrap( + db: DbConn, + user_guard: Option, +) -> Result, Status> { + use crate::db::schema::users::dsl::*; + + let has_users = db + .run(|conn| users.count().get_result::(conn)) + .await + .map_err(|_| Status::InternalServerError)? + > 0; + + let current_user = if let Some(user_guard) = user_guard { + let user_id = user_guard + .claims() + .sub + .parse::() + .map_err(|_| Status::Unauthorized)?; + db.run(move |conn| { + users + .filter(id.eq(user_id)) + .select(User::as_select()) + .first::(conn) + .optional() + }) + .await + .map_err(|_| Status::InternalServerError)? + .map(user_summary) + } else { + None + }; + + Ok(Json(BootstrapResponse { + has_users, + current_user, + })) +} + +#[openapi(tag = "Users")] +#[get("/api/v1/users")] +pub async fn list_users( + db: DbConn, + _admin_guard: AdminGuard, +) -> Result>, Status> { + use crate::db::schema::users::dsl::*; + + let users_list = db + .run(|conn| { + users + .order(username.asc()) + .select(User::as_select()) + .load::(conn) + }) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(Json(users_list.into_iter().map(user_summary).collect())) +} + +#[openapi(tag = "Users")] +#[put( + "/api/v1/users/", + format = "json", + data = "" +)] +pub async fn update_user( + db: DbConn, + _admin_guard: AdminGuard, + target_user_id: i32, + user_form: Json, +) -> Result, Status> { + use crate::db::schema::users::dsl as users_dsl; + + let form = user_form.into_inner(); + let next_username = form.username.trim().to_string(); + if next_username.is_empty() { + return Err(Status::BadRequest); + } + let next_admin = form.admin; + + let next_birthday = form + .birthday + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let profile_image_upload = form.profile_image_upload; + let remove_profile_image = form.remove_profile_image.unwrap_or(false); + let next_preferred_languages = serialize_preferred_metadata_languages( + form.preferred_metadata_languages + .unwrap_or_else(default_preferred_metadata_languages), + ); + let conflict_username = next_username.clone(); + + let existing_profile_image_path = db + .run(move |conn| { + let existing_user = users_dsl::users + .filter(users_dsl::id.eq(target_user_id)) + .select(User::as_select()) + .first::(conn) + .optional() + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + + if existing_user.admin && !next_admin { + let admin_count = users_dsl::users + .filter(users_dsl::admin.eq(true)) + .count() + .get_result::(conn) + .map_err(|_| Status::InternalServerError)?; + if admin_count <= 1 { + return Err(Status::BadRequest); + } + } + + let conflicting_username = users_dsl::users + .filter(users_dsl::id.ne(target_user_id)) + .filter(users_dsl::username.eq(&conflict_username)) + .count() + .get_result::(conn) + .map_err(|_| Status::InternalServerError)? + > 0; + if conflicting_username { + return Err(Status::Conflict); + } + + Ok(existing_user.profile_image_path) + }) + .await?; + + let (next_profile_image_path, pending_uploaded_image_path) = + if let Some(upload) = profile_image_upload { + let uploaded_path = store_profile_image(upload).await?; + (Some(uploaded_path.clone()), Some(uploaded_path)) + } else if remove_profile_image { + (None, None) + } else { + (existing_profile_image_path.clone(), None) + }; + + let update_result = db + .run(move |conn| { + diesel::update(users_dsl::users.filter(users_dsl::id.eq(target_user_id))) + .set(( + users_dsl::username.eq(next_username), + users_dsl::admin.eq(next_admin), + users_dsl::birthday.eq(next_birthday), + users_dsl::profile_image_path.eq(next_profile_image_path), + users_dsl::preferred_metadata_languages_json.eq(next_preferred_languages), + )) + .execute(conn) + .map_err(|_| Status::Conflict)?; + + users_dsl::users + .filter(users_dsl::id.eq(target_user_id)) + .select(User::as_select()) + .first::(conn) + .map_err(|_| Status::InternalServerError) + }) + .await; + + let updated_user = match update_result { + Ok(user) => user, + Err(error) => { + if let Some(uploaded_path) = pending_uploaded_image_path.as_deref() { + let _ = remove_managed_profile_image(uploaded_path).await; + } + return Err(error); + } + }; + + if updated_user.profile_image_path != existing_profile_image_path { + if let Some(old_path) = existing_profile_image_path.as_deref() { + let _ = remove_managed_profile_image(old_path).await; + } + } + + Ok(Json(user_summary(updated_user))) } #[openapi(tag = "Users")] @@ -40,6 +298,10 @@ pub async fn create_user( } let form = user_form.into_inner(); + let next_username = form.username.trim().to_string(); + if next_username.is_empty() { + return Err(Status::BadRequest); + } // Hash password using BCrypt let hashed_password = match crate::auth::hash_password(&form.password) { @@ -63,18 +325,197 @@ pub async fn create_user( None }; + let profile_image_upload = form.profile_image_upload; + let next_profile_image_path = if let Some(upload) = profile_image_upload { + Some(store_profile_image(upload).await?) + } else { + None + }; + let user = User { id: 0, // This will be auto-incremented by SQLite - username: form.username, + username: next_username, password: hashed_password, pin: hashed_pin, - admin: form.admin, + admin: existing_count == 0 || form.admin, + birthday: form + .birthday + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + profile_image_path: next_profile_image_path.clone(), + preferred_metadata_languages_json: serialize_preferred_metadata_languages( + form.preferred_metadata_languages + .unwrap_or_else(default_preferred_metadata_languages), + ), }; // Insert new user - db.run(move |conn| diesel::insert_into(users).values(&user).execute(conn)) + let insert_result = db + .run(move |conn| diesel::insert_into(users).values(&user).execute(conn)) + .await; + if insert_result.is_err() { + if let Some(uploaded_path) = next_profile_image_path.as_deref() { + let _ = remove_managed_profile_image(uploaded_path).await; + } + return Err(Status::InternalServerError); + } + + Ok("User created") +} + +#[get("/api/v1/user-profile-images/")] +pub async fn get_user_profile_image(filename: &str) -> Result { + if !is_safe_profile_image_filename(filename) { + return Err(Status::NotFound); + } + + let root = profile_image_root(); + let image_path = root.join(filename); + if !image_path.starts_with(&root) { + return Err(Status::NotFound); + } + + NamedFile::open(image_path) + .await + .map_err(|_| Status::NotFound) +} + +async fn store_profile_image(upload: ProfileImageUploadForm) -> Result { + let (bytes, extension) = validate_profile_image(upload)?; + let hash = Sha256::digest(&bytes); + let hash_prefix = hash[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Status::InternalServerError)? + .as_millis(); + let filename = format!("profile-{timestamp}-{hash_prefix}.{extension}"); + let root = profile_image_root(); + fs::create_dir_all(&root) + .await + .map_err(|_| Status::InternalServerError)?; + let image_path = root.join(&filename); + fs::write(image_path, bytes) .await .map_err(|_| Status::InternalServerError)?; + Ok(filename) +} - Ok("User created") +fn validate_profile_image( + upload: ProfileImageUploadForm +) -> Result<(Vec, &'static str), Status> { + let declared_mime_type = upload.mime_type.trim().to_ascii_lowercase(); + if !matches!( + declared_mime_type.as_str(), + "image/jpeg" | "image/png" | "image/webp" | "image/gif" + ) { + return Err(Status::UnsupportedMediaType); + } + + let data_base64 = upload.data_base64.trim(); + let bytes = general_purpose::STANDARD + .decode(data_base64) + .map_err(|_| Status::BadRequest)?; + if bytes.is_empty() { + return Err(Status::BadRequest); + } + if bytes.len() > PROFILE_IMAGE_MAX_BYTES { + return Err(Status::PayloadTooLarge); + } + + let format = image::guess_format(&bytes).map_err(|_| Status::UnsupportedMediaType)?; + let extension = match format { + image::ImageFormat::Jpeg => "jpg", + image::ImageFormat::Png => "png", + image::ImageFormat::WebP => "webp", + image::ImageFormat::Gif => "gif", + _ => return Err(Status::UnsupportedMediaType), + }; + + Ok((bytes, extension)) +} + +fn profile_image_root() -> PathBuf { + let env = Environment::from_usize(CURRENT_ENV.load(Ordering::Relaxed)); + let data_dir = match env { + Environment::Test => PathBuf::from("./test_data"), + Environment::Production => PathBuf::from(current_settings().general.data_dir), + }; + data_dir.join("users").join("profile-images") +} + +fn user_summary(user: User) -> UserSummary { + UserSummary { + id: user.id, + username: user.username, + admin: user.admin, + birthday: user.birthday, + profile_image_url: user.profile_image_path.and_then(|path| { + if is_safe_profile_image_filename(&path) { + Some(format!("{PROFILE_IMAGE_ROUTE_PREFIX}{path}")) + } else { + None + } + }), + preferred_metadata_languages: parse_preferred_metadata_languages( + &user.preferred_metadata_languages_json, + ), + } +} + +async fn remove_managed_profile_image(path: &str) -> Result<(), Status> { + if !is_safe_profile_image_filename(path) { + return Ok(()); + } + let root = profile_image_root(); + let image_path = root.join(path); + if image_path.starts_with(&root) { + let _ = fs::remove_file(image_path).await; + } + Ok(()) +} + +fn is_safe_profile_image_filename(filename: &str) -> bool { + !filename.is_empty() + && Path::new(filename) + .file_name() + .is_some_and(|file_name| file_name == filename) + && filename.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) +} + +pub fn default_preferred_metadata_languages() -> Vec { + vec!["en-US".to_string()] +} + +pub fn parse_preferred_metadata_languages(value: &str) -> Vec { + serde_json::from_str::>(value) + .unwrap_or_default() + .into_iter() + .map(|language| language.trim().to_string()) + .filter(|language| !language.is_empty()) + .fold(Vec::new(), |mut languages, language| { + if !languages.contains(&language) { + languages.push(language); + } + languages + }) + .into_iter() + .chain(default_preferred_metadata_languages()) + .fold(Vec::new(), |mut languages, language| { + if !languages.contains(&language) { + languages.push(language); + } + languages + }) +} + +pub fn serialize_preferred_metadata_languages(languages: Vec) -> String { + serde_json::to_string(&parse_preferred_metadata_languages( + &serde_json::to_string(&languages).unwrap_or_else(|_| "[]".into()), + )) + .unwrap_or_else(|_| "[\"en-US\"]".into()) } diff --git a/crates/server/tests/fixtures/mod.rs b/crates/server/tests/fixtures/mod.rs index 44842db1..508dddce 100644 --- a/crates/server/tests/fixtures/mod.rs +++ b/crates/server/tests/fixtures/mod.rs @@ -5,19 +5,21 @@ use std::path::PathBuf; // lib imports use diesel::Connection; use diesel::sqlite::SqliteConnection; -use diesel_migrations::MigrationHarness; use rocket::http::Status; use rocket::local::asynchronous::Client; use rstest::fixture; use serde_json::json; // local imports -use koko::db::MIGRATIONS; +use koko::db::revert_all_sqlite_migrations; use koko::globals::CURRENT_ENV; use koko::web::rocket; // test imports -use crate::test_utils::{TestResponse, make_request}; +use crate::test_utils::{ + TestResponse, + make_request, +}; pub struct TestDb { pub client: Client, @@ -28,7 +30,7 @@ impl Drop for TestDb { fn drop(&mut self) { if self.db_path.exists() { if let Ok(mut conn) = SqliteConnection::establish(self.db_path.to_str().unwrap()) { - let _ = conn.revert_all_migrations(MIGRATIONS); + let _ = revert_all_sqlite_migrations(&mut conn); } // Sleep to allow processes to release the database file diff --git a/crates/server/tests/main.rs b/crates/server/tests/main.rs index a203a9ea..849f6834 100644 --- a/crates/server/tests/main.rs +++ b/crates/server/tests/main.rs @@ -1,5 +1,7 @@ pub mod test_auth; -pub mod test_dependencies; +pub mod test_media; +pub mod test_metadata; +#[cfg(feature = "tray")] pub mod test_tray; pub mod test_utils; pub mod test_web; diff --git a/crates/server/tests/test_auth.rs b/crates/server/tests/test_auth.rs index dbf9ecdf..9923b4fe 100644 --- a/crates/server/tests/test_auth.rs +++ b/crates/server/tests/test_auth.rs @@ -1,7 +1,10 @@ //! Authentication tests for the application. // lib imports -use chrono::{Duration, Utc}; +use chrono::{ + Duration, + Utc, +}; use rstest::rstest; // local imports @@ -181,9 +184,20 @@ fn test_claims_struct_functionality() { } #[test] -#[should_panic(expected = "Invalid role constant")] fn test_auth_guard_invalid_role_constant() { // This should panic because role constant 99 is not valid // We test the panic by calling the role() method with an invalid const generic - let _ = AuthGuard::<99>::role(); + let result = std::panic::catch_unwind(AuthGuard::<99>::role); + let panic = result.expect_err("Invalid role constant should panic"); + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + + assert!( + message.contains("Invalid role constant"), + "unexpected panic message: {}", + message + ); } diff --git a/crates/server/tests/test_dependencies/mod.rs b/crates/server/tests/test_dependencies/mod.rs deleted file mode 100644 index 2d08ad92..00000000 --- a/crates/server/tests/test_dependencies/mod.rs +++ /dev/null @@ -1,86 +0,0 @@ -// lib imports -use rstest::rstest; - -// local imports -use koko::dependencies::get_dependencies; - -#[rstest] -#[case("Apache-2.0")] -#[case("BSD-2-Clause")] -#[case("BSD-3-Clause")] -#[case("CC0-1.0")] -#[case("ISC")] -#[case("MIT")] -#[case("MPL-2.0")] -#[case("NCSA")] -#[case("Unicode-3.0")] -#[case("Unlicense")] -#[case("Zlib")] -fn test_individual_license_compatibility(#[case] license: &str) { - assert!( - is_license_compatible(license), - "License '{}' should be compatible", - license - ); -} - -#[rstest] -#[case("GPL-3.0")] -#[case("AGPL-3.0")] -#[case("Custom License")] -#[case("Proprietary")] -fn test_individual_license_incompatibility(#[case] license: &str) { - assert!( - !is_license_compatible(license), - "License '{}' should be incompatible", - license - ); -} - -fn is_license_compatible(license: &str) -> bool { - let compatible_licenses = vec![ - // compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses - // format: https://spdx.github.io/license-list-data/ - "Apache-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "CC0-1.0", - "ISC", - "MIT", - "MPL-2.0", - "NCSA", - "Unicode-3.0", - "Unlicense", - "Zlib", - ]; - - compatible_licenses.iter().any(|&l| license.contains(l)) -} - -/// Deps that are allowed to have incompatible licenses. -fn dependency_exceptions() -> Vec<&'static str> { - vec![ - "koko", - "dlopen2_derive", // https://github.com/OpenByteDev/dlopen2/issues/20 - "ring", // https://github.com/briansmith/ring/blob/main/LICENSE - ] -} - -#[test] -fn test_dependencies_licenses() { - let dependencies = get_dependencies().unwrap(); - - for package in dependencies { - if dependency_exceptions().contains(&package.name.as_str()) { - continue; - } - - let license = package.license.as_deref().unwrap_or(""); - assert!( - is_license_compatible(license), - "License '{}' of package {} is not compatible", - license, - package.name - ); - } -} diff --git a/crates/server/tests/test_media.rs b/crates/server/tests/test_media.rs new file mode 100644 index 00000000..db0b5ac4 --- /dev/null +++ b/crates/server/tests/test_media.rs @@ -0,0 +1,4190 @@ +// standard imports +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{ + AtomicU64, + Ordering, +}; + +// lib imports +use diesel::Connection; +use diesel::RunQueryDsl; +use diesel::SqliteConnection; + +// local imports +use koko::config::{ + FfmpegSettings, + MediaLibraryKind, + MediaLibraryScanner, + MediaLibrarySettings, + MetadataProviderId, +}; +use koko::db::run_pending_sqlite_migrations; +use koko::media::{ + LibraryScanStatus, + ShowMetadataDescendantPlan, + ShowMetadataEpisodePlan, + ShowMetadataSeasonPlan, + apply_user_playback_context_to_detail, + delete_missing_media_items, + get_item_youtube_theme_collection_references, + get_item_youtube_theme_provider_references, + get_library_files, + get_media_home, + get_media_home_with_preferred_languages, + get_media_item, + get_media_item_with_preferred_languages, + get_persisted_library_summaries, + get_preferred_item_metadata_link, + get_user_playback_progress, + infer_episode_number, + infer_season_number, + inspect_libraries, + inspect_transcoding_capability, + list_automatic_metadata_candidates, + list_automatic_metadata_refresh_candidates, + list_library_settings, + list_media_item_children, + list_media_items, + mark_metadata_match_attempted, + remove_library_setting, + replace_library_settings, + resolve_local_item_artwork_path, + resolve_media_item_source_path, + search_media_items, + sync_library_catalog, + upsert_playback_progress, + upsert_show_metadata_descendant_items, +}; +use koko::metadata::{ + ArtworkKind, + ProviderMetadataCollection, + ProviderMetadataDetails, + ProviderMetadataExtra, + StoredMetadataSnapshot, + get_preferred_item_metadata_link_for_languages, + get_primary_item_metadata_link, + set_item_metadata_refresh_state, + upsert_item_metadata_link, + upsert_item_metadata_snapshot, + upsert_secondary_collection_theme_song_url, +}; + +static MEDIA_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(diesel::QueryableByName)] +struct SqlCountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, +} + +fn sql_count( + connection: &mut SqliteConnection, + sql: &str, +) -> i64 { + diesel::sql_query(sql) + .get_result::(connection) + .expect("Expected SQL count") + .count +} + +fn assert_scanner_hash(hash: &str) { + let valid_imohash = hash + .strip_prefix("imohash:") + .map(|value| { + value.len() == 32 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .unwrap_or(false); + + assert!( + valid_imohash, + "Expected scanner hash with imohash prefix, got {hash}" + ); +} + +fn unique_temp_dir(name: &str) -> PathBuf { + let test_id = MEDIA_TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + + std::env::temp_dir().join(format!("koko_{}_{}_{}", name, test_id, timestamp)) +} + +fn create_test_connection(name: &str) -> (SqliteConnection, PathBuf) { + let db_path = unique_temp_dir(name).with_extension("db"); + let mut connection = SqliteConnection::establish(&db_path.to_string_lossy()) + .expect("Failed to establish SQLite test connection"); + + run_pending_sqlite_migrations(&mut connection).expect("Failed to run test migrations"); + + (connection, db_path) +} + +#[test] +fn test_inspect_libraries_counts_media_types() { + let root = unique_temp_dir("library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(root.join("cover.jpg"), b"image").unwrap(); + fs::write(root.join("book.epub"), b"book").unwrap(); + fs::write(nested.join("notes.txt"), b"other").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Primary library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries.len(), 1); + + let summary = &summaries[0]; + assert_eq!(summary.status, LibraryScanStatus::Available); + assert_eq!(summary.total_files, 5); + assert_eq!(summary.video_files, 2); + assert_eq!(summary.audio_files, 1); + assert_eq!(summary.image_files, 1); + assert_eq!(summary.book_files, 1); + assert_eq!(summary.other_files, 0); + assert!(summary.error.is_none()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_auto_scanner_resolves_to_library_kind_scanner() { + let root = unique_temp_dir("auto_scanner_defaults"); + fs::create_dir_all(&root).unwrap(); + + let base_library = |kind: MediaLibraryKind| MediaLibrarySettings { + name: format!("{kind:?}"), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind, + scanner: MediaLibraryScanner::Auto, + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }; + + let summaries = inspect_libraries(&[ + base_library(MediaLibraryKind::Movies), + base_library(MediaLibraryKind::Shows), + base_library(MediaLibraryKind::Music), + base_library(MediaLibraryKind::Photos), + base_library(MediaLibraryKind::Books), + base_library(MediaLibraryKind::Mixed), + ]); + + assert_eq!(summaries[0].scanner, MediaLibraryScanner::Movies); + assert_eq!(summaries[1].scanner, MediaLibraryScanner::Shows); + assert_eq!(summaries[2].scanner, MediaLibraryScanner::Music); + assert_eq!(summaries[3].scanner, MediaLibraryScanner::Photos); + assert_eq!(summaries[4].scanner, MediaLibraryScanner::Books); + assert_eq!(summaries[5].scanner, MediaLibraryScanner::Directory); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_explicit_scanner_controls_inventory_independently_from_library_kind() { + let root = unique_temp_dir("explicit_scanner_inventory"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("album.flac"), b"audio").unwrap(); + + let summaries = inspect_libraries(&[MediaLibrarySettings { + name: "Movie library with music scanner".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: MediaLibraryScanner::Music, + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]); + + assert_eq!(summaries[0].kind, MediaLibraryKind::Movies); + assert_eq!(summaries[0].scanner, MediaLibraryScanner::Music); + assert_eq!(summaries[0].total_files, 1); + assert_eq!(summaries[0].video_files, 0); + assert_eq!(summaries[0].audio_files, 1); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_inspect_libraries_detects_missing_and_empty_paths() { + let missing_path = unique_temp_dir("missing_library") + .to_string_lossy() + .to_string(); + let libraries = vec![ + MediaLibrarySettings { + name: "Empty".into(), + path: String::new(), + paths: vec![], + recursive: true, + kind: MediaLibraryKind::Mixed, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + MediaLibrarySettings { + name: "Missing".into(), + path: missing_path.clone(), + paths: vec![missing_path], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + ]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries[0].status, LibraryScanStatus::EmptyPath); + assert_eq!(summaries[1].status, LibraryScanStatus::MissingPath); +} + +#[test] +fn test_movie_library_ignores_sidecar_audio_and_json_files() { + let root = unique_temp_dir("movie_library_filtering"); + fs::create_dir_all(&root).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("theme.mp3"), b"audio").unwrap(); + fs::write(root.join("movie.json"), b"metadata").unwrap(); + fs::write(root.join("poster.jpg"), b"image").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let summaries = inspect_libraries(&libraries); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].total_files, 1); + assert_eq!(summaries[0].video_files, 1); + assert_eq!(summaries[0].audio_files, 0); + assert_eq!(summaries[0].image_files, 0); + assert_eq!(summaries[0].other_files, 0); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn test_inspect_transcoding_capability_reports_missing_binary() { + let settings = FfmpegSettings { + ffmpeg_path: "koko-ffmpeg-missing-binary".into(), + ffprobe_path: "koko-ffprobe-missing-binary".into(), + ..FfmpegSettings::default() + }; + + let capability = inspect_transcoding_capability(&settings); + assert!(!capability.ffmpeg.available); + assert!(!capability.ffprobe.available); + assert!(capability.ffmpeg.error.is_some()); + assert!(capability.ffprobe.error.is_some()); +} + +#[test] +fn test_sync_library_catalog_persists_library_and_inventory() { + let root = unique_temp_dir("persist_library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Persistent library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + + assert_eq!(persisted.len(), 1); + let library = &persisted[0]; + assert!(library.id > 0); + assert_eq!(library.status, LibraryScanStatus::Available); + assert_eq!(library.total_files, 3); + assert_eq!(library.video_files, 2); + assert_eq!(library.audio_files, 1); + + let files = get_library_files(&mut connection, library.id).unwrap(); + assert_eq!(files.len(), 3); + assert_eq!(files[0].library_id, library.id); + assert!(files.iter().any(|file| file.relative_path == "movie.mkv")); + assert!(files.iter().any(|file| file.relative_path == "song.mp3")); + assert!( + files + .iter() + .any(|file| file.relative_path == "nested/episode.mp4") + ); + let movie_file = files + .iter() + .find(|file| file.relative_path == "movie.mkv") + .unwrap(); + let nested_episode_file = files + .iter() + .find(|file| file.relative_path == "nested/episode.mp4") + .unwrap(); + assert_scanner_hash(&movie_file.file_hash); + assert_scanner_hash(&nested_episode_file.file_hash); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_updates_incrementally() { + let root = unique_temp_dir("incremental_library_scan"); + fs::create_dir_all(&root).unwrap(); + + fs::write(root.join("movie.mkv"), b"original-video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Incremental library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_incremental"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let first_library = &first_sync[0]; + let first_files = get_library_files(&mut connection, first_library.id).unwrap(); + let first_movie = first_files + .iter() + .find(|file| file.relative_path == "movie.mkv") + .unwrap(); + + fs::remove_file(root.join("song.mp3")).unwrap(); + fs::write(root.join("movie.mkv"), b"updated-video-content").unwrap(); + fs::write(root.join("cover.jpg"), b"image").unwrap(); + + let second_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let second_library = &second_sync[0]; + let second_files = get_library_files(&mut connection, second_library.id).unwrap(); + let second_movie = second_files + .iter() + .find(|file| file.relative_path == "movie.mkv") + .unwrap(); + + assert_eq!( + second_library.scan_revision, + first_library.scan_revision + 1 + ); + assert_eq!(second_library.total_files, 2); + assert_eq!(second_library.video_files, 1); + assert_eq!(second_library.image_files, 1); + assert_eq!(second_files.len(), 3); + assert_eq!(first_movie.id, second_movie.id); + assert_scanner_hash(&first_movie.file_hash); + assert_scanner_hash(&second_movie.file_hash); + assert_ne!(first_movie.file_hash, second_movie.file_hash); + assert!( + second_files + .iter() + .any(|file| file.relative_path == "cover.jpg") + ); + let missing_song = second_files + .iter() + .find(|file| file.relative_path == "song.mp3") + .expect("Expected removed file to stay in trash state"); + assert!(missing_song.missing_since.is_some()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_replaces_legacy_file_hashes() { + let root = unique_temp_dir("legacy_file_hash_refresh"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Legacy hash library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("legacy_file_hash_refresh_db"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = first_sync[0].id; + let original_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_scanner_hash(&original_file.file_hash); + + diesel::sql_query("UPDATE media_files SET file_hash = 'stat-v1:legacy-hash'") + .execute(&mut connection) + .unwrap(); + let legacy_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_eq!(legacy_file.file_hash, "stat-v1:legacy-hash"); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let refreshed_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_eq!(refreshed_file.file_hash, original_file.file_hash); + + diesel::sql_query( + "UPDATE media_files SET file_hash = \ + 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", + ) + .execute(&mut connection) + .unwrap(); + let sha256_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_eq!( + sha256_file.file_hash, + "sha256:0000000000000000000000000000000000000000000000000000000000000000" + ); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let migrated_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_eq!(migrated_file.file_hash, original_file.file_hash); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_repairs_path_duplicate_hashes() { + let root = unique_temp_dir("path_duplicate_hash_refresh"); + fs::create_dir_all(&root).unwrap(); + let movie_path = root.join("movie.mkv"); + fs::write(&movie_path, b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Path duplicate library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("path_duplicate_hash_refresh_db"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = first_sync[0].id; + let original_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + let expected_hash = original_file.file_hash.clone(); + let physical_path = movie_path.to_string_lossy().to_string(); + let legacy_path = format!("{}\\legacy-path\\movie.mkv", root.to_string_lossy()); + + diesel::sql_query( + "UPDATE media_files SET path = ?, file_hash = 'stat-v1:legacy-membership' WHERE id = ?", + ) + .bind::(&legacy_path) + .bind::(original_file.id) + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "INSERT INTO media_files (path, file_size, modified_at, media_kind, file_hash) VALUES (?, \ + ?, ?, 'video', 'stat-v1:duplicate-physical')", + ) + .bind::(&physical_path) + .bind::(original_file.file_size) + .bind::, _>(original_file.modified_at) + .execute(&mut connection) + .unwrap(); + assert_eq!( + sql_count(&mut connection, "SELECT COUNT(*) AS count FROM media_files"), + 2 + ); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let refreshed_file = get_library_files(&mut connection, library_id) + .unwrap() + .pop() + .unwrap(); + assert_eq!(refreshed_file.file_hash, expected_hash); + assert_eq!( + sql_count(&mut connection, "SELECT COUNT(*) AS count FROM media_files"), + 1, + "Expected stale unreferenced physical file rows to be pruned after scan" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_preserves_inventory_when_root_is_missing() { + let root = unique_temp_dir("missing_root_preserves_inventory"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Removable drive".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("missing_root_catalog"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = first_sync[0].id; + assert_eq!( + get_library_files(&mut connection, library_id) + .unwrap() + .len(), + 1 + ); + assert_eq!( + list_media_items(&mut connection, Some(library_id)) + .unwrap() + .len(), + 1 + ); + + fs::remove_dir_all(&root).unwrap(); + + let second_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + assert_eq!(second_sync[0].status, LibraryScanStatus::MissingPath); + assert_eq!(second_sync[0].total_files, 0); + let files = get_library_files(&mut connection, library_id).unwrap(); + assert_eq!(files.len(), 1); + assert!( + files[0].missing_since.is_some(), + "Expected the file to be marked missing instead of deleted" + ); + let items = list_media_items(&mut connection, Some(library_id)).unwrap(); + assert_eq!(items.len(), 1); + assert!( + items[0].missing_since.is_some(), + "Expected the item to be marked missing instead of deleted" + ); + let summaries = get_persisted_library_summaries(&mut connection).unwrap(); + let summary = summaries + .iter() + .find(|summary| summary.id == library_id) + .unwrap(); + assert_eq!(summary.missing_files, 1); + assert_eq!(summary.missing_items, 1); + + drop(connection); + fs::remove_file(db_path).unwrap(); +} + +#[cfg(windows)] +#[test] +fn test_sync_library_catalog_skips_unreadable_file_without_poisoning_root() { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + + let root = unique_temp_dir("unreadable_file_preserves_root"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("readable.mkv"), b"video").unwrap(); + let locked_path = root.join("locked.mkv"); + fs::write(&locked_path, b"locked-video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("unreadable_file_preserves_root_db"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = first_sync[0].id; + assert_eq!( + get_library_files(&mut connection, library_id) + .unwrap() + .len(), + 2 + ); + + let locked_handle = OpenOptions::new() + .write(true) + .share_mode(0) + .open(&locked_path) + .unwrap(); + diesel::sql_query("UPDATE media_files SET file_hash = 'stat-v1:legacy-hash'") + .execute(&mut connection) + .unwrap(); + + let second_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + assert_eq!(second_sync[0].status, LibraryScanStatus::Available); + assert_eq!(second_sync[0].total_files, 1); + assert!( + second_sync[0] + .error + .as_deref() + .is_some_and(|error| error.contains("locked.mkv")) + ); + + let files = get_library_files(&mut connection, library_id).unwrap(); + assert_eq!(files.len(), 2); + let readable = files + .iter() + .find(|file| file.relative_path == "readable.mkv") + .unwrap(); + assert!( + readable.missing_since.is_none(), + "Expected readable files in the root to be restored after an unreadable sibling is skipped" + ); + let locked = files + .iter() + .find(|file| file.relative_path == "locked.mkv") + .unwrap(); + assert!( + locked.missing_since.is_some(), + "Expected only the unreadable file to remain marked missing" + ); + + drop(locked_handle); + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_delete_missing_media_items_removes_active_rows_without_losing_history_metadata() { + let root = unique_temp_dir("delete_missing_media_items"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("delete_missing_media_items_db"); + diesel::sql_query( + "INSERT INTO users (username, password, pin, admin) VALUES ('alice', 'hash', NULL, 1)", + ) + .execute(&mut connection) + .unwrap(); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = persisted[0].id; + let movie = list_media_items(&mut connection, Some(library_id)) + .unwrap() + .pop() + .unwrap(); + + upsert_item_metadata_link( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("Primary metadata overview.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/poster.jpg".into()), + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + &ProviderMetadataDetails { + collections: vec![ProviderMetadataCollection { + external_id: "matrix".into(), + name: Some("The Matrix Collection".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + theme_song_url: None, + }], + ..ProviderMetadataDetails::default() + }, + "primary", + None, + ) + .unwrap(); + upsert_playback_progress( + &mut connection, + 1, + movie.id, + 120_000, + movie.duration_ms, + false, + ) + .unwrap(); + + #[derive(diesel::QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let collection_item_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM metadata_collection_items WHERE media_item_id = ?", + ) + .bind::(movie.id) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(collection_item_count, 1); + + fs::remove_dir_all(&root).unwrap(); + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + + let cleanup = delete_missing_media_items(&mut connection, Some(library_id), None).unwrap(); + assert_eq!(cleanup.deleted_files, 1); + assert_eq!(cleanup.deleted_items, 1); + assert_eq!( + list_media_items(&mut connection, Some(library_id)) + .unwrap() + .len(), + 0 + ); + assert_eq!( + get_library_files(&mut connection, library_id) + .unwrap() + .len(), + 0 + ); + assert!( + get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .is_none() + ); + + let metadata_link_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM item_metadata_links WHERE media_item_id = ?", + ) + .bind::(movie.id) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(metadata_link_count, 1); + let playback_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM playback_progress WHERE media_item_id = ?", + ) + .bind::(movie.id) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(playback_count, 1); + let collection_item_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM metadata_collection_items WHERE media_item_id = ?", + ) + .bind::(movie.id) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(collection_item_count, 0); + + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let restored = list_media_items(&mut connection, Some(library_id)).unwrap(); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].id, movie.id); + assert!(restored[0].missing_since.is_none()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_marks_missing_files_without_deleting_rows() { + let first_root = unique_temp_dir("partial_scan_first_root"); + let second_root = unique_temp_dir("partial_scan_second_root"); + fs::create_dir_all(&first_root).unwrap(); + fs::create_dir_all(&second_root).unwrap(); + fs::write(first_root.join("removed.mkv"), b"video").unwrap(); + fs::write(second_root.join("preserved.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Multi-root library".into(), + path: first_root.to_string_lossy().to_string(), + paths: vec![ + first_root.to_string_lossy().to_string(), + second_root.to_string_lossy().to_string(), + ], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("partial_missing_root_catalog"); + let first_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library_id = first_sync[0].id; + assert_eq!( + get_library_files(&mut connection, library_id) + .unwrap() + .len(), + 2 + ); + + fs::remove_file(first_root.join("removed.mkv")).unwrap(); + fs::remove_dir_all(&second_root).unwrap(); + + let second_sync = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + assert_eq!(second_sync[0].status, LibraryScanStatus::Available); + assert!( + second_sync[0] + .error + .as_deref() + .unwrap() + .contains("does not exist") + ); + + let files = get_library_files(&mut connection, library_id).unwrap(); + assert_eq!(files.len(), 2); + let preserved = files + .iter() + .find(|file| file.relative_path == "preserved.mkv") + .expect("Expected file under missing root to stay in trash state"); + assert!(preserved.missing_since.is_some()); + let removed = files + .iter() + .find(|file| file.relative_path == "removed.mkv") + .expect("Expected missing file under scanned root to stay in trash state"); + assert!(removed.missing_since.is_some()); + + drop(connection); + fs::remove_dir_all(first_root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_queries_and_search_work_on_persisted_catalog() { + let root = unique_temp_dir("item_query_library_scan"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + + fs::write(root.join("movie.mkv"), b"video").unwrap(); + fs::write(root.join("song.mp3"), b"audio").unwrap(); + fs::write(nested.join("episode.mp4"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Query library".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Mixed, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_item_queries"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + assert_eq!(items.len(), 3); + assert!(items.iter().any(|item| item.display_title == "movie")); + + let movie = items + .iter() + .find(|item| item.relative_path == "movie.mkv") + .unwrap(); + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected movie detail to exist"); + assert_eq!(detail.display_title, "movie"); + assert_eq!(detail.relative_path, "movie.mkv"); + + let search_results = search_media_items(&mut connection, "episode", Some(library.id)).unwrap(); + assert_eq!(search_results.len(), 1); + assert_eq!(search_results[0].relative_path, "nested/episode.mp4"); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_shows_library_builds_show_season_episode_hierarchy() { + let root = unique_temp_dir("shows_library_hierarchy"); + let season = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("shows_library_hierarchy_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + + assert_eq!(items.len(), 3); + + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items + .iter() + .find(|item| item.item_type == "season") + .unwrap(); + let episode = items + .iter() + .find(|item| item.item_type == "episode") + .unwrap(); + + assert_eq!(show.parent_id, None); + assert_eq!(show.child_count, 1); + assert_eq!(show.available_season_count, Some(1)); + assert_eq!(season.parent_id, Some(show.id)); + assert_eq!(season.child_count, 1); + assert_eq!(episode.parent_id, Some(season.id)); + assert!(episode.playable); + assert_eq!(episode.season_number, Some(1)); + assert_eq!(episode.episode_number, Some(1)); + + let show_detail = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail to exist"); + assert_eq!(show_detail.available_season_count, Some(1)); + assert_eq!(show_detail.children.len(), 1); + assert_eq!(show_detail.children[0].id, season.id); + + let season_detail = get_media_item(&mut connection, season.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected season detail to exist"); + assert_eq!(season_detail.hierarchy.len(), 1); + assert_eq!(season_detail.hierarchy[0].id, show.id); + assert_eq!(season_detail.children.len(), 1); + assert_eq!(season_detail.children[0].id, episode.id); + + let episode_detail = get_media_item(&mut connection, episode.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected episode detail to exist"); + assert_eq!(episode_detail.hierarchy.len(), 2); + assert_eq!(episode_detail.hierarchy[0].id, show.id); + assert_eq!(episode_detail.hierarchy[1].id, season.id); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_show_metadata_placeholders_materialize_missing_descendants() { + let root = unique_temp_dir("show_metadata_missing_descendants"); + let season_one = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season_one).unwrap(); + fs::write(season_one.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("show_metadata_missing_descendants_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let show = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "show") + .expect("Expected show item to exist"); + + let descendants = upsert_show_metadata_descendant_items( + &mut connection, + show.id, + &ShowMetadataDescendantPlan { + seasons: vec![ + ShowMetadataSeasonPlan { + season_number: 1, + display_title: None, + }, + ShowMetadataSeasonPlan { + season_number: 2, + display_title: None, + }, + ], + episodes: vec![ + ShowMetadataEpisodePlan { + season_number: 1, + episode_number: 1, + display_title: None, + }, + ShowMetadataEpisodePlan { + season_number: 1, + episode_number: 2, + display_title: None, + }, + ShowMetadataEpisodePlan { + season_number: 1, + episode_number: 3, + display_title: None, + }, + ShowMetadataEpisodePlan { + season_number: 2, + episode_number: 1, + display_title: None, + }, + ], + }, + ) + .unwrap(); + + assert!(descendants.seasons_by_number.contains_key(&1)); + assert!(descendants.seasons_by_number.contains_key(&2)); + assert!(descendants.episodes_by_number.contains_key(&(1, 1))); + assert!(descendants.episodes_by_number.contains_key(&(1, 2))); + assert!(descendants.episodes_by_number.contains_key(&(1, 3))); + assert!(!descendants.episodes_by_number.contains_key(&(2, 1))); + + let show_children = list_media_item_children(&mut connection, show.id).unwrap(); + assert_eq!(show_children.len(), 2); + let show_after_descendants = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail after descendant materialization"); + assert_eq!(show_after_descendants.child_count, 2); + assert_eq!(show_after_descendants.available_season_count, Some(1)); + let listed_show_after_descendants = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .into_iter() + .find(|item| item.id == show.id) + .expect("Expected show summary after descendant materialization"); + assert_eq!(listed_show_after_descendants.child_count, 2); + assert_eq!( + listed_show_after_descendants.available_season_count, + Some(1) + ); + let season_one_item = show_children + .iter() + .find(|item| item.season_number == Some(1)) + .expect("Expected season 1"); + let season_two_item = show_children + .iter() + .find(|item| item.season_number == Some(2)) + .expect("Expected season 2"); + assert_eq!(season_one_item.child_count, 3); + assert_eq!(season_two_item.child_count, 0); + assert!(season_two_item.missing_since.is_some()); + + let season_one_children = + list_media_item_children(&mut connection, season_one_item.id).unwrap(); + assert_eq!(season_one_children.len(), 3); + let local_episode = season_one_children + .iter() + .find(|item| item.episode_number == Some(1)) + .expect("Expected local episode"); + assert!(local_episode.playable); + assert!(local_episode.missing_since.is_none()); + for episode_number in [2, 3] { + let missing_episode = season_one_children + .iter() + .find(|item| item.episode_number == Some(episode_number)) + .expect("Expected missing episode placeholder"); + assert!(!missing_episode.playable); + assert!(missing_episode.missing_since.is_some()); + } + assert!( + list_media_item_children(&mut connection, season_two_item.id) + .unwrap() + .is_empty() + ); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let show_children_after_rescan = list_media_item_children(&mut connection, show.id).unwrap(); + assert_eq!(show_children_after_rescan.len(), 2); + + fs::write( + season_one.join("Mock Show - S01E02 - Added Locally.mkv"), + b"video", + ) + .unwrap(); + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let season_one_item = list_media_item_children(&mut connection, show.id) + .unwrap() + .into_iter() + .find(|item| item.season_number == Some(1)) + .expect("Expected season 1 after adding episode"); + let season_one_children = + list_media_item_children(&mut connection, season_one_item.id).unwrap(); + let episode_twos = season_one_children + .iter() + .filter(|item| item.episode_number == Some(2)) + .collect::>(); + assert_eq!(episode_twos.len(), 1); + assert!(episode_twos[0].playable); + assert!(episode_twos[0].missing_since.is_none()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_episode_number_parser_handles_show_titles_before_sxxexx() { + assert_eq!( + infer_episode_number("Marvel's Agents of S H I E L D (2013) - S03E01 - Laws of Nature.mkv"), + Some(1) + ); + assert_eq!( + infer_episode_number("Marvel's Agents of S H I E L D (2013) - 3x22 - Ascension.mkv"), + Some(22) + ); + assert_eq!( + infer_season_number("Marvel's Agents of S H I E L D (2013) - S03E01 - Laws of Nature.mkv"), + Some(3) + ); + assert_eq!( + infer_season_number("Marvel's Agents of S H I E L D (2013) - 3x22 - Ascension.mkv"), + Some(3) + ); +} + +#[test] +fn test_show_scanner_parses_documented_show_naming_forms() { + let root = unique_temp_dir("show_scanner_documented_names"); + let season = root + .join("Example Show (2020) [tmdb-12345] [tvdb-67890]") + .join("Series 2"); + fs::create_dir_all(&season).unwrap(); + fs::write( + season.join("Example Show - 2x03 - Episode Name.mkv"), + b"video", + ) + .unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: MediaLibraryScanner::Shows, + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("show_scanner_documented_names_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let files = get_library_files(&mut connection, library.id).unwrap(); + assert_eq!(files[0].display_title, "Episode Name"); + + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items + .iter() + .find(|item| item.item_type == "season") + .unwrap(); + let episode = items + .iter() + .find(|item| item.item_type == "episode") + .unwrap(); + + assert_eq!(show.display_title, "Example Show"); + assert_eq!(season.display_title, "Season 2"); + assert_eq!(season.season_number, Some(2)); + assert_eq!(episode.display_title, "Episode Name"); + assert_eq!(episode.season_number, Some(2)); + assert_eq!(episode.episode_number, Some(3)); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_shows_are_included_in_automatic_metadata_candidates() { + let root = unique_temp_dir("automatic_show_metadata_candidates"); + let season = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("automatic_show_metadata_candidates_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let show = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "show") + .expect("Expected show item to exist"); + + let candidates = list_automatic_metadata_candidates(&mut connection, None, 8).unwrap(); + assert!(candidates.iter().any(|candidate| { + candidate.item_id == show.id + && candidate.display_title == show.display_title + && candidate.library_kind == MediaLibraryKind::Shows + })); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_refresh_metadata_candidates_retry_previously_attempted_unlinked_movies() { + let root = unique_temp_dir("retry_attempted_movie_metadata_candidates"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("The Matrix (1999).mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("retry_attempted_movie_metadata_candidates_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let movie = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .expect("Expected movie item to exist"); + + mark_metadata_match_attempted(&mut connection, movie.id, 123).unwrap(); + + let automatic_candidates = + list_automatic_metadata_candidates(&mut connection, Some(library.id), 8).unwrap(); + assert!( + automatic_candidates + .iter() + .all(|candidate| candidate.item_id != movie.id) + ); + + let refresh_candidates = + list_automatic_metadata_refresh_candidates(&mut connection, Some(library.id), 8).unwrap(); + assert!(refresh_candidates.iter().any(|candidate| { + candidate.item_id == movie.id + && candidate.display_title == movie.display_title + && candidate.metadata_providers == vec![MetadataProviderId::Tmdb] + })); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_show_recently_added_collapses_to_episode_season_or_show() { + let root = unique_temp_dir("show_recently_added_collapsed"); + let alpha = root.join("Alpha Show").join("Season 1"); + let beta = root.join("Beta Show").join("Season 1"); + let gamma_season_1 = root.join("Gamma Show").join("Season 1"); + let gamma_season_2 = root.join("Gamma Show").join("Season 2"); + fs::create_dir_all(&alpha).unwrap(); + fs::create_dir_all(&beta).unwrap(); + fs::create_dir_all(&gamma_season_1).unwrap(); + fs::create_dir_all(&gamma_season_2).unwrap(); + + fs::write(alpha.join("Alpha Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E02 - Second.mkv"), b"video").unwrap(); + fs::write( + gamma_season_1.join("Gamma Show - S01E01 - Pilot.mkv"), + b"video", + ) + .unwrap(); + fs::write( + gamma_season_2.join("Gamma Show - S02E01 - Return.mkv"), + b"video", + ) + .unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("show_recently_added_collapsed_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + let recently_added = home + .shelves + .iter() + .find(|shelf| shelf.id == "recently_added") + .expect("Expected recently added shelf"); + + assert_eq!(recently_added.items.len(), 3); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "episode") + .count(), + 1 + ); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "season") + .count(), + 1 + ); + assert_eq!( + recently_added + .items + .iter() + .filter(|item| item.item_type == "show") + .count(), + 1 + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_home_includes_real_collection_summaries() { + let root = unique_temp_dir("home_collection_summaries"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("collection-one.mkv"), b"video").unwrap(); + fs::write(root.join("collection-two.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("home_collection_summaries_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + + for item in items.iter().take(2) { + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: format!("movie-{}", item.id), + media_type: Some("movie".into()), + title: Some(item.display_title.clone()), + overview: Some("Part of a test collection.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + serde_json::json!({ + "title": item.display_title, + "overview": "Part of a test collection.", + "release_date": "1999-03-31", + "belongs_to_collection": { + "id": 4242, + "name": "Test Saga", + "overview": "A linked movie collection for home browsing.", + "poster_path": "/poster.jpg", + "backdrop_path": "/backdrop.jpg" + } + }) + .to_string(), + ), + }; + upsert_item_metadata_snapshot(&mut connection, item.id, &snapshot).unwrap(); + } + upsert_item_metadata_snapshot( + &mut connection, + items[0].id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: format!("movie-{}", items[0].id), + media_type: Some("movie".into()), + title: Some(items[0].display_title.clone()), + overview: Some("Parte de una colección de prueba.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "es-ES".into(), + provider_locale_key: Some("es-ES".into()), + provider_payload_json: Some( + serde_json::json!({ + "title": items[0].display_title, + "overview": "Parte de una colección de prueba.", + "release_date": "1999-03-31", + "belongs_to_collection": { + "id": 4242, + "name": "Saga de Prueba", + "overview": "Una colección de películas para navegar.", + "poster_path": "/poster-es.jpg", + "backdrop_path": "/backdrop-es.jpg" + } + }) + .to_string(), + ), + }, + ) + .unwrap(); + + let home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + assert_eq!(home.collections.len(), 1); + assert_eq!(home.collections[0].name, "Test Saga"); + assert_eq!(home.collections[0].item_count, 2); + assert_eq!(home.collections[0].item_ids.len(), 2); + let spanish_home = get_media_home_with_preferred_languages( + &mut connection, + None, + Some(library.id), + &["es-ES".into()], + ) + .unwrap(); + assert_eq!(spanish_home.collections.len(), 1); + assert_eq!(spanish_home.collections[0].name, "Saga de Prueba"); + assert_eq!(spanish_home.collections[0].item_count, 2); + + #[derive(diesel::QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + #[derive(diesel::QueryableByName)] + struct TextRow { + #[diesel(sql_type = diesel::sql_types::Nullable)] + value: Option, + } + let collection_count = diesel::sql_query("SELECT COUNT(*) AS count FROM metadata_collections") + .get_result::(&mut connection) + .unwrap() + .count; + let collection_item_count = + diesel::sql_query("SELECT COUNT(*) AS count FROM metadata_collection_items") + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(collection_count, 2); + assert_eq!(collection_item_count, 3); + let collection_references = get_item_youtube_theme_collection_references( + &mut connection, + items[0].id, + MetadataProviderId::Themerr, + ) + .unwrap(); + assert_eq!(collection_references.len(), 1); + assert_eq!( + ( + collection_references[0].1.as_str(), + collection_references[0].2.as_str(), + collection_references[0].3.as_str(), + ), + ("collection", "tmdb", "4242") + ); + upsert_secondary_collection_theme_song_url( + &mut connection, + collection_references[0].0, + MetadataProviderId::Themerr, + collection_references[0].1.as_str(), + collection_references[0].2.as_str(), + collection_references[0].3.as_str(), + "https://youtu.be/SLBACEP6LsI", + ) + .unwrap(); + let themerr_collection_name = diesel::sql_query( + "SELECT name AS value FROM metadata_collections WHERE provider_id = 'themerr' AND \ + relation_kind = 'secondary'", + ) + .get_result::(&mut connection) + .unwrap() + .value; + assert_eq!(themerr_collection_name, None); + + diesel::sql_query( + "UPDATE metadata_collections SET name = 'Test Saga' WHERE provider_id = 'themerr' AND \ + relation_kind = 'secondary'", + ) + .execute(&mut connection) + .unwrap(); + upsert_secondary_collection_theme_song_url( + &mut connection, + collection_references[0].0, + MetadataProviderId::Themerr, + collection_references[0].1.as_str(), + collection_references[0].2.as_str(), + collection_references[0].3.as_str(), + "https://youtu.be/SLBACEP6LsI", + ) + .unwrap(); + let repaired_themerr_collection_name = diesel::sql_query( + "SELECT name AS value FROM metadata_collections WHERE provider_id = 'themerr' AND \ + relation_kind = 'secondary'", + ) + .get_result::(&mut connection) + .unwrap() + .value; + assert_eq!(repaired_themerr_collection_name, None); + let merged_home_after_theme = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + assert_eq!(merged_home_after_theme.collections[0].name, "Test Saga"); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_restores_file_name_as_display_title() { + let root = unique_temp_dir("title_policy_refresh"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("Movie Name.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("media_catalog_title_policy_refresh"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + diesel::sql_query("UPDATE media_file_libraries SET display_title = ? WHERE media_item_id = ?") + .bind::, _>(Some( + "Embedded Metadata Title".to_string(), + )) + .bind::(movie.id) + .execute(&mut connection) + .unwrap(); + + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let refreshed = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected item detail after refresh"); + assert_eq!(refreshed.display_title, "Movie Name"); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_movie_scan_strips_year_provider_tags_and_format_from_display_title() { + let root = unique_temp_dir("movie_title_parser"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("Top Gun- Maverick (2022) - 1080p [tmdb-361743] [tvdb-12345].mkv"), + b"video", + ) + .unwrap(); + fs::write( + root.join("Beyond The Sky (2018) - Bluray-1080p.mkv"), + b"video", + ) + .unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("movie_title_parser_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let titles = items + .into_iter() + .map(|item| item.display_title) + .collect::>(); + + assert!( + titles.iter().any(|title| title == "Top Gun: Maverick"), + "Expected cleaned Top Gun title in {titles:?}" + ); + assert!( + titles.iter().any(|title| title == "Beyond The Sky"), + "Expected cleaned Beyond The Sky title in {titles:?}" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_detail_includes_linked_metadata_presentation() { + let root = unique_temp_dir("item_detail_linked_metadata"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("media_catalog_item_detail_linked_metadata"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("A hacker discovers reality is a simulation.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/poster.jpg".into()), + backdrop_url: Some("https://image.tmdb.org/t/p/w1280/backdrop.jpg".into()), + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + serde_json::json!({ + "tagline": "Welcome to the real world.", + "overview": "A hacker discovers reality is a simulation.", + "genres": [ + { "id": 28, "name": "Action" }, + { "id": 878, "name": "Science Fiction" } + ], + "release_date": "1999-03-31", + "vote_average": 8.2, + "images": { + "logos": [ + { "file_path": "/matrix-logo.png" } + ] + }, + "release_dates": { + "results": [ + { + "iso_3166_1": "US", + "release_dates": [ + { "certification": "R" } + ] + } + ] + }, + "videos": { + "results": [ + { + "site": "YouTube", + "type": "Trailer", + "official": true, + "name": "Official Trailer", + "key": "vKQi3bBA1y8" + } + ] + } + }) + .to_string(), + ), + }; + let stored_summary = + upsert_item_metadata_snapshot(&mut connection, movie.id, &snapshot).unwrap(); + let stored_link = get_primary_item_metadata_link(&mut connection, movie.id) + .unwrap() + .expect("Expected stored metadata link"); + assert_eq!( + stored_link.logo_url.as_deref(), + Some("https://image.tmdb.org/t/p/w500/matrix-logo.png") + ); + assert_eq!(stored_link.rating, Some(8.2)); + assert_eq!(stored_link.content_rating.as_deref(), Some("R")); + assert_eq!( + stored_summary.trailer_title.as_deref(), + Some("Official Trailer") + ); + + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected linked movie detail to exist"); + let expected_poster_url = format!("/api/v1/items/{}/artwork?kind=poster", movie.id); + let expected_backdrop_url = format!("/api/v1/items/{}/artwork?kind=backdrop", movie.id); + assert_eq!( + detail.tagline.as_deref(), + Some("Welcome to the real world.") + ); + assert_eq!(detail.release_year, Some(1999)); + assert_eq!(detail.genres, vec!["Action", "Science Fiction"]); + assert_eq!( + detail.logo_url.as_deref(), + Some("/api/v1/items/1/artwork?kind=logo") + ); + assert_eq!(detail.rating, Some(8.2)); + assert_eq!(detail.content_rating.as_deref(), Some("R")); + assert!(detail.artwork_updated_at.is_some()); + assert_eq!(detail.trailer_title.as_deref(), Some("Official Trailer")); + assert_eq!( + detail.trailer_url.as_deref(), + Some("https://www.youtube.com/watch?v=vKQi3bBA1y8") + ); + assert_eq!( + detail.poster_url.as_deref(), + Some(expected_poster_url.as_str()) + ); + assert_eq!( + detail.backdrop_url.as_deref(), + Some(expected_backdrop_url.as_str()) + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_metadata_snapshot_upsert_keeps_shallow_people_without_enrichment() { + let root = unique_temp_dir("metadata_shallow_people_sync"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("metadata_shallow_people_sync_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + serde_json::json!({ + "credits": { + "cast": [ + { + "cast_id": 7, + "credit_id": "52fe425bc3a36847f80181c7", + "id": 6384, + "name": "Keanu Reeves", + "character": "Neo", + "order": 0, + "profile_path": "/keanu.jpg" + } + ], + "crew": [] + } + }) + .to_string(), + ), + }; + + let summary = upsert_item_metadata_snapshot(&mut connection, movie.id, &snapshot).unwrap(); + assert_eq!(summary.people.len(), 1); + assert_eq!(summary.people[0].name, "Keanu Reeves"); + assert_eq!(summary.people[0].role.as_deref(), Some("Actor")); + assert_eq!(summary.people[0].character_name.as_deref(), Some("Neo")); + assert_eq!( + summary.people[0].image_url.as_deref(), + Some("https://image.tmdb.org/t/p/w185/keanu.jpg") + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM item_metadata_people" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_person_credits" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_people WHERE biography IS NULL" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_person_external_ids WHERE source IN ('cast', \ + 'credit')" + ), + 0 + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_metadata_snapshot_upsert_stores_person_external_ids() { + let root = unique_temp_dir("metadata_person_external_ids"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("metadata_person_external_ids_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let movie = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + serde_json::json!({ + "credits": { + "cast": [ + { + "id": 6384, + "name": "Keanu Reeves", + "character": "Neo", + "order": 0, + "koko_person": { + "id": 6384, + "name": "Keanu Reeves", + "external_ids": { + "imdb_id": "nm0000206", + "wikidata_id": "Q43416" + } + } + } + ], + "crew": [] + } + }) + .to_string(), + ), + }, + ) + .unwrap(); + + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_person_external_ids WHERE source = 'tmdb' AND \ + external_id = '6384'" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_person_external_ids WHERE source = 'imdb' AND \ + external_id = 'nm0000206'" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM metadata_person_external_ids WHERE source = 'wikidata' \ + AND external_id = 'Q43416'" + ), + 1 + ); + assert_eq!( + sql_count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('metadata_people') WHERE name = \ + 'identity_key'" + ), + 0 + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_metadata_links_can_store_multiple_locales_for_same_provider() { + let root = unique_temp_dir("metadata_link_locales"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("metadata_link_locales_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("English overview.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("Matrix".into()), + overview: Some("Resumen en espanol.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "es-ES".into(), + provider_locale_key: Some("es-ES".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + let preferred = get_preferred_item_metadata_link_for_languages( + &mut connection, + movie.id, + &[ + "es-ES".to_string(), + "en-US".to_string(), + ], + ) + .unwrap() + .expect("Expected localized metadata link"); + assert_eq!(preferred.locale_key, "es-ES"); + assert_eq!(preferred.overview.as_deref(), Some("Resumen en espanol.")); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_detail_uses_primary_metadata_link_only() { + let root = unique_temp_dir("item_detail_primary_metadata_only"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("media_catalog_item_detail_primary_metadata_only"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("Primary metadata overview.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/poster.jpg".into()), + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + diesel::sql_query( + "INSERT INTO item_metadata_links (media_item_id, provider_id, external_id, title, \ + overview, tagline, artwork_url, backdrop_url, release_year, media_type, relation_kind, \ + match_state, cached_artwork_path, cached_backdrop_path, refresh_state, \ + refresh_interval_seconds, last_refreshed_at, next_refresh_at, refresh_error, updated_at) \ + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL, NULL, ?, ?, ?, NULL, NULL, ?, 0, NULL, NULL, NULL, \ + ?)", + ) + .bind::(movie.id) + .bind::("musicbrainz") + .bind::("musicbrainz:collection:999") + .bind::, _>(Some( + "Wrong Collection Title".to_string(), + )) + .bind::, _>(Some( + "Wrong collection overview.".to_string(), + )) + .bind::, _>(Some( + "https://example.invalid/wrong.jpg".to_string(), + )) + .bind::, _>(Some("collection".to_string())) + .bind::("collection") + .bind::("linked") + .bind::("fresh") + .bind::, _>(Some(i64::MAX - 1)) + .execute(&mut connection) + .unwrap(); + + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected item detail to exist"); + assert_eq!(detail.display_title, "The Matrix"); + assert_eq!( + detail.overview.as_deref(), + Some("Primary metadata overview.") + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_detail_merges_metadata_links_by_library_provider_order() { + let root = unique_temp_dir("item_detail_metadata_provider_merge"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::Tvdb, + ], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("item_detail_metadata_provider_merge_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let movie = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("Priority Title".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: "movie-603".into(), + media_type: Some("movie".into()), + title: Some("Fallback Title".into()), + overview: Some("Fallback overview from lower priority provider.".into()), + artwork_url: None, + backdrop_url: None, + release_year: Some(2003), + locale_key: "en-US".into(), + provider_locale_key: Some("eng".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected item detail"); + assert_eq!(detail.display_title, "Priority Title"); + assert_eq!( + detail.overview.as_deref(), + Some("Fallback overview from lower priority provider.") + ); + assert_eq!(detail.release_year, Some(1999)); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_secondary_theme_song_metadata_is_stored_and_presented() { + let root = unique_temp_dir("secondary_theme_song_metadata"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::Themerr, + ], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("secondary_theme_song_metadata_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let movie = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + let secondary = upsert_item_metadata_link( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Themerr, + external_id: "movie:tmdb:603".into(), + media_type: Some("movie".into()), + title: None, + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "en-US".into(), + provider_locale_key: None, + provider_payload_json: None, + }, + &ProviderMetadataDetails { + theme_song_url: Some("https://youtu.be/SLBACEP6LsI".into()), + ..ProviderMetadataDetails::default() + }, + "secondary", + None, + ) + .unwrap(); + assert_eq!( + secondary.theme_song_url.as_deref(), + Some("https://www.youtube.com/watch?v=SLBACEP6LsI") + ); + + let detail = get_media_item(&mut connection, movie.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected item detail"); + assert_eq!( + detail.theme_song_url.as_deref(), + Some("https://www.youtube.com/watch?v=SLBACEP6LsI") + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_secondary_trailer_metadata_is_stored_per_locale_and_presented() { + let root = unique_temp_dir("secondary_trailer_metadata"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::TrailerDb, + ], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Manual, + metadata_languages: vec!["en-US".into(), "es-ES".into()], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("secondary_trailer_metadata_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let movie = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + upsert_item_metadata_link( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::TrailerDb, + external_id: "movie:imdb:tt0133093".into(), + media_type: Some("movie".into()), + title: None, + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "en-US".into(), + provider_locale_key: Some("en".into()), + provider_payload_json: None, + }, + &ProviderMetadataDetails { + trailer_title: Some("Official Trailer".into()), + trailer_url: Some("https://youtu.be/abcdefghijk".into()), + ..ProviderMetadataDetails::default() + }, + "secondary", + None, + ) + .unwrap(); + + let secondary = upsert_item_metadata_link( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::TrailerDb, + external_id: "movie:imdb:tt0133093".into(), + media_type: Some("movie".into()), + title: None, + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "es-ES".into(), + provider_locale_key: Some("es".into()), + provider_payload_json: None, + }, + &ProviderMetadataDetails { + trailer_title: Some("Trailer oficial".into()), + trailer_url: Some("https://youtu.be/ZYXWVUT9876".into()), + extras: vec![ProviderMetadataExtra { + extra_type: "trailer".into(), + title: Some("Trailer oficial".into()), + url: "https://youtu.be/ZYXWVUT9876".into(), + duration_seconds: Some(148), + thumbnail_url: None, + sort_order: 0, + }], + ..ProviderMetadataDetails::default() + }, + "secondary", + None, + ) + .unwrap(); + assert_eq!(secondary.locale_key, "es-ES"); + assert_eq!(secondary.provider_locale_key.as_deref(), Some("es")); + assert_eq!(secondary.trailer_title.as_deref(), Some("Trailer oficial")); + + let detail = get_media_item_with_preferred_languages( + &mut connection, + movie.id, + &root.to_string_lossy(), + &["es-ES".into()], + ) + .unwrap() + .expect("Expected item detail"); + assert_eq!(detail.trailer_title.as_deref(), Some("Trailer oficial")); + assert_eq!( + detail.trailer_url.as_deref(), + Some("https://www.youtube.com/watch?v=ZYXWVUT9876") + ); + assert_eq!(detail.extras.len(), 1); + let spanish_extra = detail + .extras + .iter() + .find(|extra| extra.url == "https://www.youtube.com/watch?v=ZYXWVUT9876") + .expect("Expected Spanish trailer extra"); + assert_eq!(spanish_extra.extra_type, "trailer"); + assert_eq!(spanish_extra.title.as_deref(), Some("Trailer oficial")); + assert_eq!( + spanish_extra.url.as_str(), + "https://www.youtube.com/watch?v=ZYXWVUT9876" + ); + assert_eq!(spanish_extra.duration_seconds, Some(148)); + assert_eq!( + spanish_extra.thumbnail_url.as_deref(), + Some("https://i.ytimg.com/vi/ZYXWVUT9876/hqdefault.jpg") + ); + + #[derive(diesel::QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + #[derive(diesel::QueryableByName)] + struct ExternalMediaRow { + #[diesel(sql_type = diesel::sql_types::Nullable)] + title: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + thumbnail_url: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + duration_seconds: Option, + } + + let trailer_extra_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM metadata_extras WHERE extra_type = 'trailer'", + ) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(trailer_extra_count, 2); + let external_media_count = diesel::sql_query("SELECT COUNT(*) AS count FROM external_media") + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(external_media_count, 2); + let spanish_external_media = diesel::sql_query( + "SELECT title, thumbnail_url, duration_seconds FROM external_media \ + WHERE url = 'https://www.youtube.com/watch?v=ZYXWVUT9876'", + ) + .get_result::(&mut connection) + .unwrap(); + assert_eq!( + spanish_external_media.title.as_deref(), + Some("Trailer oficial") + ); + assert_eq!( + spanish_external_media.thumbnail_url.as_deref(), + Some("https://i.ytimg.com/vi/ZYXWVUT9876/hqdefault.jpg") + ); + assert_eq!(spanish_external_media.duration_seconds, Some(148)); + let legacy_url_column_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM pragma_table_info('item_metadata_links') WHERE name IN \ + ('trailer_title', 'trailer_url', 'theme_song_url')", + ) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(legacy_url_column_count, 0); + let normalized_url_column_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM pragma_table_info('external_media') WHERE name = \ + 'normalized_url'", + ) + .get_result::(&mut connection) + .unwrap() + .count; + assert_eq!(normalized_url_column_count, 0); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_metadata_refresh_target_change_clears_cached_artwork_paths() { + let root = unique_temp_dir("metadata_refresh_target_change_clears_cache"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("metadata_refresh_target_change_clears_cache_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.first().unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("A hacker discovers reality is a simulation.".into()), + artwork_url: Some("https://image.tmdb.org/t/p/w500/poster.jpg".into()), + backdrop_url: Some("https://image.tmdb.org/t/p/w1280/backdrop.jpg".into()), + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + diesel::sql_query( + "UPDATE item_metadata_links SET cached_artwork_path = ?, cached_backdrop_path = ? WHERE \ + media_item_id = ?", + ) + .bind::, _>(Some( + "C:/tmp/old-poster.jpg".to_string(), + )) + .bind::, _>(Some( + "C:/tmp/old-backdrop.jpg".to_string(), + )) + .bind::(movie.id) + .execute(&mut connection) + .unwrap(); + + set_item_metadata_refresh_state( + &mut connection, + movie.id, + MetadataProviderId::Tmdb, + "999", + Some("movie"), + "pending", + None, + ) + .unwrap(); + + let link = get_primary_item_metadata_link(&mut connection, movie.id) + .unwrap() + .expect("Expected metadata link to exist"); + assert!( + link.cached_artwork_path.is_none(), + "Expected cached artwork path to clear when metadata target changes" + ); + assert!( + link.cached_backdrop_path.is_none(), + "Expected cached backdrop path to clear when metadata target changes" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_preferred_item_metadata_link_rejects_episode_tmdb_link_with_wrong_show_external_id() { + let root = unique_temp_dir("preferred_episode_metadata_link"); + let alpha = root.join("Alpha Show").join("Season 1"); + let beta = root.join("Beta Show").join("Season 1"); + fs::create_dir_all(&alpha).unwrap(); + fs::create_dir_all(&beta).unwrap(); + fs::write(alpha.join("Alpha Show - S01E01.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E01.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("preferred_episode_metadata_link_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let alpha_show = items + .iter() + .find(|item| item.item_type == "show" && item.display_title.contains("Alpha")) + .unwrap(); + let alpha_episode = items + .iter() + .find(|item| item.item_type == "episode" && item.relative_path.contains("Alpha Show")) + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + alpha_show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "111".into(), + media_type: Some("tv".into()), + title: Some("Alpha Show".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + alpha_episode.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "tv:222:season:1:episode:1".into(), + media_type: Some("tv_episode".into()), + title: Some("Wrong Episode".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + let preferred = get_preferred_item_metadata_link(&mut connection, alpha_episode.id).unwrap(); + assert!( + preferred.is_none(), + "Expected mismatched TMDB episode link to be rejected" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_resolve_media_item_source_path_rejects_mismatched_backing_file() { + let root = unique_temp_dir("reject_mismatched_episode_backing_file"); + let alpha = root.join("Alpha Show").join("Season 1"); + let beta = root.join("Beta Show").join("Season 1"); + fs::create_dir_all(&alpha).unwrap(); + fs::create_dir_all(&beta).unwrap(); + fs::write(alpha.join("Alpha Show - S01E01.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E01.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("reject_mismatched_episode_backing_file_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let alpha_episode = items + .iter() + .find(|item| item.item_type == "episode" && item.relative_path.contains("Alpha Show")) + .unwrap(); + let beta_episode = items + .iter() + .find(|item| item.item_type == "episode" && item.relative_path.contains("Beta Show")) + .unwrap(); + + diesel::sql_query( + "UPDATE media_file_libraries SET media_item_id = NULL WHERE media_item_id = ?", + ) + .bind::(alpha_episode.id) + .execute(&mut connection) + .unwrap(); + diesel::sql_query("UPDATE media_file_libraries SET media_item_id = ? WHERE media_item_id = ?") + .bind::(alpha_episode.id) + .bind::(beta_episode.id) + .execute(&mut connection) + .unwrap(); + + let resolved = resolve_media_item_source_path(&mut connection, alpha_episode.id).unwrap(); + assert!( + resolved.is_none(), + "Expected no source path when the linked media file path does not match the item path" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_persisted_library_summaries_include_metadata_refresh_progress() { + let root = unique_temp_dir("library_refresh_progress"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("The Matrix (1999).mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("library_refresh_progress_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.iter().find(|item| item.item_type == "movie").unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: Some("A hacker discovers reality is a simulation.".into()), + artwork_url: Some( + "https://image.tmdb.org/t/p/w500/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg".into(), + ), + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }, + ) + .unwrap(); + + let fresh_summary = get_persisted_library_summaries(&mut connection).unwrap(); + assert_eq!(fresh_summary[0].metadata_refresh_total, 1); + assert_eq!(fresh_summary[0].metadata_refresh_pending, 0); + assert_eq!(fresh_summary[0].metadata_refresh_completed, 1); + assert_eq!(fresh_summary[0].metadata_refresh_failed, 0); + + set_item_metadata_refresh_state( + &mut connection, + movie.id, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "pending", + None, + ) + .unwrap(); + let pending_summary = get_persisted_library_summaries(&mut connection).unwrap(); + assert_eq!(pending_summary[0].metadata_refresh_total, 1); + assert_eq!(pending_summary[0].metadata_refresh_pending, 1); + assert_eq!(pending_summary[0].metadata_refresh_completed, 0); + assert_eq!(pending_summary[0].metadata_refresh_failed, 0); + + set_item_metadata_refresh_state( + &mut connection, + movie.id, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "error", + Some("boom"), + ) + .unwrap(); + let error_summary = get_persisted_library_summaries(&mut connection).unwrap(); + assert_eq!(error_summary[0].metadata_refresh_total, 1); + assert_eq!(error_summary[0].metadata_refresh_pending, 0); + assert_eq!(error_summary[0].metadata_refresh_completed, 1); + assert_eq!(error_summary[0].metadata_refresh_failed, 1); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_secondary_theme_song_reference_inherits_from_linked_show() { + let root = unique_temp_dir("secondary_theme_song_reference_show"); + let season_dir = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season_dir).unwrap(); + fs::write( + season_dir.join("Mock Show - S01E01 - Winter Is Coming.mkv"), + b"video", + ) + .unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("secondary_theme_song_reference_show_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items + .iter() + .find(|item| item.item_type == "season") + .unwrap(); + let episode = items + .iter() + .find(|item| item.item_type == "episode") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "1399".into(), + media_type: Some("tv".into()), + title: Some("Mock Show".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2011), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some(serde_json::json!({ "name": "Mock Show" }).to_string()), + }, + ) + .unwrap(); + + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + show.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![("show".into(), "tmdb".into(), "1399".into())] + ); + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + season.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![("show".into(), "tmdb".into(), "1399".into())] + ); + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + episode.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![("show".into(), "tmdb".into(), "1399".into())] + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_item_detail_theme_song_inherits_from_show() { + let root = unique_temp_dir("show_theme_song_detail_inheritance"); + let season_dir = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season_dir).unwrap(); + fs::write( + season_dir.join("Mock Show - S01E01 - Winter Is Coming.mkv"), + b"video", + ) + .unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::Themerr, + ], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("show_theme_song_detail_inheritance_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let show = items.iter().find(|item| item.item_type == "show").unwrap(); + let season = items + .iter() + .find(|item| item.item_type == "season") + .unwrap(); + let episode = items + .iter() + .find(|item| item.item_type == "episode") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "1399".into(), + media_type: Some("tv".into()), + title: Some("Mock Show".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2011), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some(serde_json::json!({ "name": "Mock Show" }).to_string()), + }, + ) + .unwrap(); + upsert_item_metadata_link( + &mut connection, + show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Themerr, + external_id: "show:tmdb:1399".into(), + media_type: Some("show".into()), + title: None, + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: None, + locale_key: "en-US".into(), + provider_locale_key: None, + provider_payload_json: None, + }, + &ProviderMetadataDetails { + theme_song_url: Some("https://youtu.be/uXZd_W5B7N0".into()), + ..ProviderMetadataDetails::default() + }, + "secondary", + None, + ) + .unwrap(); + + let expected_theme = Some("https://www.youtube.com/watch?v=uXZd_W5B7N0"); + let show_detail = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail"); + let season_detail = get_media_item(&mut connection, season.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected season detail"); + let episode_detail = get_media_item(&mut connection, episode.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected episode detail"); + + assert_eq!(show_detail.theme_song_url.as_deref(), expected_theme); + assert_eq!(season_detail.theme_song_url.as_deref(), expected_theme); + assert_eq!(episode_detail.theme_song_url.as_deref(), expected_theme); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_secondary_theme_song_reference_includes_external_id_fallbacks() { + let root = unique_temp_dir("secondary_theme_song_reference_movie"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("The Matrix (1999).mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("secondary_theme_song_reference_movie_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let movie = items.iter().find(|item| item.item_type == "movie").unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "603".into(), + media_type: Some("movie".into()), + title: Some("The Matrix".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(1999), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: Some( + serde_json::json!({ + "title": "The Matrix", + "imdb_id": "tt0133093" + }) + .to_string(), + ), + }, + ) + .unwrap(); + + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + movie.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![ + ("movie".into(), "tmdb".into(), "603".into()), + ("movie".into(), "imdb".into(), "tt0133093".into()), + ] + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_themerr_movie_references_prefer_tvdb_tmdb_before_imdb_external_id() { + let root = unique_temp_dir("secondary_theme_song_reference_tvdb_movie"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("Top Gun Maverick (2022).mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tvdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("secondary_theme_song_reference_tvdb_movie_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let movie = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "movie") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + movie.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: "901".into(), + media_type: Some("movie".into()), + title: Some("Top Gun: Maverick".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2022), + locale_key: "en-US".into(), + provider_locale_key: Some("eng".into()), + provider_payload_json: Some( + serde_json::json!({ + "data": { + "id": 901, + "name": "Top Gun: Maverick", + "remoteIds": [ + { + "type": 2, + "id": "tt1745960" + }, + { + "type": 12, + "id": "361743" + } + ] + } + }) + .to_string(), + ), + }, + ) + .unwrap(); + + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + movie.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![ + ("movie".into(), "tmdb".into(), "361743".into()), + ("movie".into(), "imdb".into(), "tt1745960".into()), + ] + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_themerr_references_include_tvdb_show_tmdb_fallback() { + let root = unique_temp_dir("secondary_theme_song_reference_tvdb"); + let season_dir = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season_dir).unwrap(); + fs::write(season_dir.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tvdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = + create_test_connection("secondary_theme_song_reference_tvdb_tmdb_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let show = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .into_iter() + .find(|item| item.item_type == "show") + .unwrap(); + + upsert_item_metadata_snapshot( + &mut connection, + show.id, + &StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tvdb, + external_id: "121361".into(), + media_type: Some("series".into()), + title: Some("Game of Thrones".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2011), + locale_key: "en-US".into(), + provider_locale_key: Some("eng".into()), + provider_payload_json: Some( + serde_json::json!({ + "data": { + "id": 121361, + "name": "Game of Thrones", + "remoteIds": [ + { + "type": 12, + "id": 1399 + } + ] + } + }) + .to_string(), + ), + }, + ) + .unwrap(); + + assert_eq!( + get_item_youtube_theme_provider_references( + &mut connection, + show.id, + MetadataProviderId::Themerr + ) + .unwrap(), + vec![("show".into(), "tmdb".into(), "1399".into())] + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_library_settings_are_persisted_in_database() { + let root = unique_temp_dir("persisted_library_settings_movies"); + let updated_root = unique_temp_dir("persisted_library_settings_shows"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&updated_root).unwrap(); + + let initial_libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("persisted_library_settings_db"); + let bootstrapped = replace_library_settings(&mut connection, &initial_libraries).unwrap(); + assert_eq!(bootstrapped.len(), 1); + assert_eq!(bootstrapped[0].name, "Movies"); + + let updated = replace_library_settings( + &mut connection, + &[MediaLibrarySettings { + name: "Shows".into(), + path: updated_root.to_string_lossy().to_string(), + paths: vec![updated_root.to_string_lossy().to_string()], + recursive: false, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }], + ) + .unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].name, "Shows"); + assert_eq!(updated[0].kind, MediaLibraryKind::Shows); + + assert!(remove_library_setting(&mut connection, 0).unwrap()); + assert!(list_library_settings(&mut connection).unwrap().is_empty()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(updated_root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_replace_library_settings_allows_duplicate_paths() { + let root = unique_temp_dir("persisted_library_settings_duplicate_paths"); + let movies = root.join("Movies"); + fs::create_dir_all(&movies).unwrap(); + + let (mut connection, db_path) = + create_test_connection("persisted_library_settings_duplicate_paths_db"); + let result = replace_library_settings( + &mut connection, + &[ + MediaLibrarySettings { + name: "Movies".into(), + path: movies.to_string_lossy().to_string(), + paths: vec![movies.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + MediaLibrarySettings { + name: "Shows".into(), + path: movies.to_string_lossy().to_string(), + paths: vec![movies.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![ + MetadataProviderId::Tmdb, + MetadataProviderId::Tvdb, + ], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + ], + ); + + let libraries = result.unwrap(); + assert_eq!(libraries.len(), 2); + assert_eq!(libraries[0].path, movies.to_string_lossy().to_string()); + assert_eq!(libraries[1].path, movies.to_string_lossy().to_string()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_library_catalog_initializes_scan_state_for_duplicate_paths() { + let root = unique_temp_dir("sync_library_catalog_duplicate_paths"); + let media = root.join("Media"); + fs::create_dir_all(&media).unwrap(); + fs::write(media.join("feature.mkv"), b"video").unwrap(); + + let duplicate_libraries = vec![ + MediaLibrarySettings { + name: "Movies".into(), + path: media.to_string_lossy().to_string(), + paths: vec![media.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + MediaLibrarySettings { + name: "Shows".into(), + path: media.to_string_lossy().to_string(), + paths: vec![media.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tvdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + ]; + + let (mut connection, db_path) = + create_test_connection("sync_library_catalog_duplicate_paths_db"); + replace_library_settings(&mut connection, &duplicate_libraries) + .expect("Expected duplicate-path settings to persist"); + + sync_library_catalog( + &mut connection, + &duplicate_libraries, + &FfmpegSettings::default(), + ) + .expect("Expected sync to process both duplicate-path libraries"); + + let summaries = get_persisted_library_summaries(&mut connection) + .expect("Expected persisted library summaries after sync"); + assert_eq!(summaries.len(), 2); + assert_ne!(summaries[0].id, summaries[1].id); + assert!( + summaries.iter().all(|summary| summary.scan_revision > 0), + "Expected every duplicate-path library to have scan_state initialized" + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_allows_duplicate_movie_libraries_with_same_path() { + let root = unique_temp_dir("sync_duplicate_movie_libraries_same_path"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("Example Movie.mkv"), b"video").unwrap(); + + let duplicate_libraries = vec![ + MediaLibrarySettings { + name: "Movies - TMDB".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + MediaLibrarySettings { + name: "Movies - TVDB".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tvdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + ]; + + let (mut connection, db_path) = + create_test_connection("sync_duplicate_movie_libraries_same_path_db"); + + sync_library_catalog( + &mut connection, + &duplicate_libraries, + &FfmpegSettings::default(), + ) + .expect("Expected duplicate movie libraries with the same path to sync"); + sync_library_catalog( + &mut connection, + &duplicate_libraries, + &FfmpegSettings::default(), + ) + .expect("Expected duplicate movie libraries with the same path to resync"); + + let summaries = get_persisted_library_summaries(&mut connection) + .expect("Expected persisted library summaries after sync"); + assert_eq!(summaries.len(), 2); + + for summary in summaries { + let items = list_media_items(&mut connection, Some(summary.id)) + .expect("Expected scoped media items for duplicate movie library"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].library_id, summary.id); + + let files = get_library_files(&mut connection, summary.id) + .expect("Expected scoped media files for duplicate movie library"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].library_id, summary.id); + } + + #[derive(diesel::QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let physical_file_count = diesel::sql_query("SELECT COUNT(*) AS count FROM media_files") + .get_result::(&mut connection) + .expect("Expected physical media file count") + .count; + let library_file_count = + diesel::sql_query("SELECT COUNT(*) AS count FROM media_file_libraries") + .get_result::(&mut connection) + .expect("Expected library media file membership count") + .count; + let duplicate_identity_count = diesel::sql_query( + "SELECT COUNT(*) AS count FROM (SELECT identity_key FROM media_items GROUP BY \ + identity_key HAVING COUNT(*) > 1)", + ) + .get_result::(&mut connection) + .expect("Expected duplicate identity count") + .count; + + assert_eq!(physical_file_count, 1); + assert_eq!(library_file_count, 2); + assert_eq!(duplicate_identity_count, 0); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_sync_allows_duplicate_show_libraries_with_same_path() { + let root = unique_temp_dir("sync_duplicate_show_libraries_same_path"); + let season = root.join("Example Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Example Show - S01E01.mkv"), b"video").unwrap(); + + let duplicate_libraries = vec![ + MediaLibrarySettings { + name: "TV Shows - TMDB".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + MediaLibrarySettings { + name: "TV Shows - TVDB".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tvdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }, + ]; + + let (mut connection, db_path) = + create_test_connection("sync_duplicate_show_libraries_same_path_db"); + replace_library_settings(&mut connection, &duplicate_libraries) + .expect("Expected duplicate show-library settings to persist"); + + sync_library_catalog( + &mut connection, + &duplicate_libraries, + &FfmpegSettings::default(), + ) + .expect("Expected duplicate show libraries with the same path to sync"); + + let summaries = get_persisted_library_summaries(&mut connection) + .expect("Expected persisted library summaries after sync"); + assert_eq!(summaries.len(), 2); + + #[derive(diesel::QueryableByName)] + struct CountRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + let physical_file_count = diesel::sql_query("SELECT COUNT(*) AS count FROM media_files") + .get_result::(&mut connection) + .expect("Expected physical media file count") + .count; + let library_file_count = + diesel::sql_query("SELECT COUNT(*) AS count FROM media_file_libraries") + .get_result::(&mut connection) + .expect("Expected library media file membership count") + .count; + assert_eq!( + physical_file_count, 1, + "Expected duplicate library roots to share one physical media_files row" + ); + assert_eq!( + library_file_count, 2, + "Expected one library membership per duplicate library" + ); + + for summary in summaries { + let items = list_media_items(&mut connection, Some(summary.id)) + .expect("Expected scoped media items for duplicate show library"); + assert_eq!(items.len(), 3); + assert_eq!( + items.iter().filter(|item| item.item_type == "show").count(), + 1 + ); + assert_eq!( + items + .iter() + .filter(|item| item.item_type == "season") + .count(), + 1 + ); + assert_eq!( + items + .iter() + .filter(|item| item.item_type == "episode") + .count(), + 1 + ); + + let files = get_library_files(&mut connection, summary.id) + .expect("Expected scoped media files for duplicate show library"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].library_id, summary.id); + } + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_resolve_local_item_artwork_ignores_unlinked_media_file_id_collision() { + let root = unique_temp_dir("episode_artwork_id_collision"); + let alpha = root.join("Alpha Show").join("Season 1"); + let beta = root.join("Beta Show").join("Season 1"); + let gamma = root.join("Gamma Show").join("Season 1"); + fs::create_dir_all(&alpha).unwrap(); + fs::create_dir_all(&beta).unwrap(); + fs::create_dir_all(&gamma).unwrap(); + fs::write(alpha.join("Alpha Show - S01E01.mkv"), b"video").unwrap(); + fs::write(beta.join("Beta Show - S01E01.mkv"), b"video").unwrap(); + fs::write(gamma.join("Gamma Show - S01E01.mkv"), b"video").unwrap(); + fs::write(gamma.join("poster.jpg"), b"image").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("episode_artwork_id_collision_db"); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let items = list_media_items(&mut connection, Some(library.id)).unwrap(); + let episodes = items + .into_iter() + .filter(|item| item.item_type == "episode") + .collect::>(); + let target_episode = episodes + .iter() + .find(|episode| episode.relative_path.contains("Alpha Show")) + .expect("Expected Alpha Show episode to exist"); + let fallback_episode = episodes + .iter() + .find(|episode| episode.relative_path.contains("Gamma Show")) + .expect("Expected Gamma Show episode to exist"); + + diesel::sql_query( + "UPDATE media_file_libraries SET media_item_id = NULL WHERE media_item_id = ?", + ) + .bind::(target_episode.id) + .execute(&mut connection) + .unwrap(); + diesel::sql_query("DELETE FROM media_file_libraries WHERE id = ?") + .bind::(target_episode.id) + .execute(&mut connection) + .unwrap(); + diesel::sql_query( + "INSERT INTO media_file_libraries (id, media_file_id, library_id, source_root_path, \ + relative_path, display_title, metadata_match_attempted_at, media_item_id) SELECT ?, \ + media_file_id, library_id, source_root_path, relative_path || '.id-collision', \ + display_title, metadata_match_attempted_at, ? FROM media_file_libraries WHERE \ + media_item_id = ? LIMIT 1", + ) + .bind::(target_episode.id) + .bind::(fallback_episode.id) + .bind::(fallback_episode.id) + .execute(&mut connection) + .unwrap(); + + let resolved = resolve_local_item_artwork_path( + &mut connection, + target_episode.id, + ArtworkKind::Poster, + &root.to_string_lossy(), + ) + .unwrap(); + assert!( + resolved.is_none(), + "Expected no artwork when an episode has no linked media file, got {:?}", + resolved + ); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_playback_progress_is_scoped_per_user() { + let root = unique_temp_dir("playback_progress_per_user"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("playback_progress_per_user_db"); + diesel::sql_query( + "INSERT INTO users (username, password, pin, admin) VALUES ('alice', 'hash', NULL, 1), \ + ('bob', 'hash', NULL, 0)", + ) + .execute(&mut connection) + .unwrap(); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let library = &persisted[0]; + let item = list_media_items(&mut connection, Some(library.id)) + .unwrap() + .pop() + .unwrap(); + + upsert_playback_progress( + &mut connection, + 1, + item.id, + 120_000, + item.duration_ms, + false, + ) + .unwrap(); + upsert_playback_progress( + &mut connection, + 2, + item.id, + 240_000, + item.duration_ms, + false, + ) + .unwrap(); + + let alice_home = get_media_home(&mut connection, Some(1), Some(library.id)).unwrap(); + let bob_home = get_media_home(&mut connection, Some(2), Some(library.id)).unwrap(); + let anonymous_home = get_media_home(&mut connection, None, Some(library.id)).unwrap(); + + assert_eq!(alice_home.shelves[0].items.len(), 1); + assert_eq!(bob_home.shelves[0].items.len(), 1); + assert!(anonymous_home.shelves[0].items.is_empty()); + assert_eq!(alice_home.shelves[0].items[0].id, item.id); + assert_eq!(bob_home.shelves[0].items[0].id, item.id); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_playback_progress_tracks_watch_count_and_last_watched() { + let root = unique_temp_dir("playback_progress_watch_count"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("movie.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Movies".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("playback_progress_watch_count_db"); + diesel::sql_query( + "INSERT INTO users (username, password, pin, admin) VALUES ('alice', 'hash', NULL, 1)", + ) + .execute(&mut connection) + .unwrap(); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let item = list_media_items(&mut connection, Some(persisted[0].id)) + .unwrap() + .pop() + .unwrap(); + + upsert_playback_progress( + &mut connection, + 1, + item.id, + 120_000, + item.duration_ms, + false, + ) + .unwrap(); + let progress = get_user_playback_progress(&mut connection, Some(1), item.id) + .unwrap() + .expect("Expected progress"); + assert!(!progress.completed); + assert_eq!(progress.watch_count, 0); + assert_eq!(progress.last_watched_at, None); + + upsert_playback_progress(&mut connection, 1, item.id, 600_000, item.duration_ms, true).unwrap(); + let completed = get_user_playback_progress(&mut connection, Some(1), item.id) + .unwrap() + .expect("Expected completed progress"); + assert!(completed.completed); + assert_eq!(completed.watch_count, 1); + assert!(completed.last_watched_at.is_some()); + + upsert_playback_progress(&mut connection, 1, item.id, 600_000, item.duration_ms, true).unwrap(); + let duplicate_completed = get_user_playback_progress(&mut connection, Some(1), item.id) + .unwrap() + .expect("Expected duplicate completed progress"); + assert_eq!(duplicate_completed.watch_count, 1); + + upsert_playback_progress( + &mut connection, + 1, + item.id, + 120_000, + item.duration_ms, + false, + ) + .unwrap(); + upsert_playback_progress(&mut connection, 1, item.id, 600_000, item.duration_ms, true).unwrap(); + let replay_completed = get_user_playback_progress(&mut connection, Some(1), item.id) + .unwrap() + .expect("Expected replay completed progress"); + assert_eq!(replay_completed.watch_count, 2); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} + +#[test] +fn test_show_playback_target_resumes_in_progress_episode_per_user() { + let root = unique_temp_dir("show_playback_target"); + let season = root.join("Mock Show").join("Season 1"); + fs::create_dir_all(&season).unwrap(); + fs::write(season.join("Mock Show - S01E01 - Pilot.mkv"), b"video").unwrap(); + fs::write(season.join("Mock Show - S01E02 - Followup.mkv"), b"video").unwrap(); + + let libraries = vec![MediaLibrarySettings { + name: "Shows".into(), + path: root.to_string_lossy().to_string(), + paths: vec![root.to_string_lossy().to_string()], + recursive: true, + kind: MediaLibraryKind::Shows, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }]; + + let (mut connection, db_path) = create_test_connection("show_playback_target_db"); + diesel::sql_query( + "INSERT INTO users (username, password, pin, admin) VALUES ('alice', 'hash', NULL, 1), \ + ('bob', 'hash', NULL, 0)", + ) + .execute(&mut connection) + .unwrap(); + let persisted = + sync_library_catalog(&mut connection, &libraries, &FfmpegSettings::default()).unwrap(); + let items = list_media_items(&mut connection, Some(persisted[0].id)).unwrap(); + let show = items + .iter() + .find(|item| item.item_type == "show") + .expect("Expected show"); + let season_item = items + .iter() + .find(|item| item.item_type == "season") + .expect("Expected season"); + let episode_one = items + .iter() + .find(|item| item.item_type == "episode" && item.episode_number == Some(1)) + .expect("Expected episode one"); + let episode_two = items + .iter() + .find(|item| item.item_type == "episode" && item.episode_number == Some(2)) + .expect("Expected episode two"); + + upsert_playback_progress( + &mut connection, + 1, + episode_one.id, + episode_one.duration_ms.unwrap_or(0), + episode_one.duration_ms, + true, + ) + .unwrap(); + let alice_between_episodes_home = + get_media_home(&mut connection, Some(1), Some(persisted[0].id)).unwrap(); + assert_eq!(alice_between_episodes_home.shelves[0].items.len(), 1); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0].id, + episode_two.id + ); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0].item_type, + "episode" + ); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0].display_title, + show.display_title + ); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0] + .display_subtitle + .as_deref(), + Some("S01E02") + ); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0].artwork_item_id, + Some(season_item.id) + ); + assert_eq!( + alice_between_episodes_home.shelves[0].items[0].playback_position_ms, + None + ); + + upsert_playback_progress( + &mut connection, + 1, + episode_two.id, + 90_000, + episode_two.duration_ms, + false, + ) + .unwrap(); + let alice_in_progress_home = + get_media_home(&mut connection, Some(1), Some(persisted[0].id)).unwrap(); + assert_eq!(alice_in_progress_home.shelves[0].items.len(), 1); + assert_eq!( + alice_in_progress_home.shelves[0].items[0].id, + episode_two.id + ); + assert_eq!( + alice_in_progress_home.shelves[0].items[0].playback_position_ms, + Some(90_000) + ); + assert_eq!( + alice_in_progress_home.shelves[0].items[0].display_title, + show.display_title + ); + assert_eq!( + alice_in_progress_home.shelves[0].items[0] + .display_subtitle + .as_deref(), + Some("S01E02") + ); + assert_eq!( + alice_in_progress_home.shelves[0].items[0].artwork_item_id, + Some(season_item.id) + ); + + let mut alice_detail = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail"); + apply_user_playback_context_to_detail(&mut connection, Some(1), &mut alice_detail).unwrap(); + let alice_target = alice_detail + .playback_target + .as_ref() + .expect("Expected Alice playback target"); + assert_eq!(alice_target.item_id, episode_two.id); + assert_eq!(alice_target.start_ms, 90_000); + assert_eq!(alice_target.label, "Resume S01E02"); + assert!(alice_detail.restart_playback_target.is_some()); + + let mut bob_detail = get_media_item(&mut connection, show.id, &root.to_string_lossy()) + .unwrap() + .expect("Expected show detail"); + apply_user_playback_context_to_detail(&mut connection, Some(2), &mut bob_detail).unwrap(); + let bob_target = bob_detail + .playback_target + .as_ref() + .expect("Expected Bob playback target"); + assert_eq!(bob_target.item_id, episode_one.id); + assert_eq!(bob_target.start_ms, 0); + assert_eq!(bob_target.label, "Play S01E01"); + assert!(bob_detail.restart_playback_target.is_none()); + + drop(connection); + fs::remove_dir_all(root).unwrap(); + fs::remove_file(db_path).unwrap(); +} diff --git a/crates/server/tests/test_metadata.rs b/crates/server/tests/test_metadata.rs new file mode 100644 index 00000000..477392dd --- /dev/null +++ b/crates/server/tests/test_metadata.rs @@ -0,0 +1,436 @@ +// local imports +use diesel::Connection; +use diesel::RunQueryDsl; +use diesel::connection::SimpleConnection; +use diesel::sql_types::Text; +use koko::config::{ + DatabaseMaintenanceTaskSettings, + FfmpegSettings, + MediaLibraryKind, + MediaLibrarySettings, + MetadataProviderId, + MetadataProviderSettings, + MetadataRefreshTaskSettings, + MetadataSettings, + ScheduledTaskWeekday, + ScheduledTaskWindowSettings, + ScheduledTasksSettings, + Settings, + TrashCleanupTaskSettings, + load_database_settings, + save_database_settings, + seed_database_settings, + settings_for_persistence, + settings_yaml_for_persistence, +}; +use koko::metadata::{ + StoredMetadataSnapshot, + expected_artwork_cache_path, + list_provider_statuses, + managed_metadata_asset_dir, + metadata_asset_uuid, + persist_item_metadata_assets, +}; +use std::fs; + +#[derive(diesel::QueryableByName)] +struct SettingValue { + #[diesel(sql_type = Text)] + value: String, +} + +fn use_sample_secret_store() { + std::env::set_var("KOKO_SECRET_STORE", "sample"); +} + +#[test] +fn test_metadata_provider_statuses_include_tmdb() { + let statuses = list_provider_statuses(&MetadataSettings::default()); + let tmdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb) + .expect("Expected TMDB provider to be registered"); + + assert_eq!(tmdb.display_name, "TheMovieDB"); + assert!(tmdb.enabled); + assert!(tmdb.requires_api_key); + assert!(!tmdb.configured); + assert!(tmdb.implemented); +} + +#[test] +fn test_metadata_provider_statuses_include_tvdb() { + let statuses = list_provider_statuses(&MetadataSettings::default()); + let tvdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::Tvdb) + .expect("Expected TheTVDB provider to be registered"); + + assert_eq!(tvdb.display_name, "TheTVDB"); + assert!(!tvdb.enabled); + assert!(tvdb.requires_api_key); + assert!(!tvdb.configured); + assert!(tvdb.implemented); +} + +#[test] +fn test_metadata_settings_default_includes_tvdb_provider_entry() { + let settings = MetadataSettings::default(); + let tvdb = settings + .providers + .iter() + .find(|provider| provider.id == MetadataProviderId::Tvdb) + .expect("Expected MetadataSettings::default() to include a TheTVDB provider entry"); + + assert!(!tvdb.enabled); + assert_eq!(tvdb.language, "en-US"); +} + +#[test] +fn test_metadata_provider_statuses_include_trailerdb() { + let statuses = list_provider_statuses(&MetadataSettings::default()); + let trailerdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::TrailerDb) + .expect("Expected TrailerDB provider to be registered"); + + assert_eq!(trailerdb.display_name, "TrailerDB"); + assert!(trailerdb.enabled); + assert!(!trailerdb.requires_api_key); + assert!(trailerdb.configured); + assert!(trailerdb.implemented); + assert_eq!( + trailerdb.extends_provider_ids, + vec![MetadataProviderId::Tmdb] + ); +} + +#[test] +fn test_metadata_provider_statuses_respect_api_key_configuration() { + let settings = MetadataSettings { + providers: vec![MetadataProviderSettings { + id: MetadataProviderId::Tmdb, + enabled: true, + api_key: Some("test-key".into()), + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: "en-US".into(), + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1_000, + }], + refresh_interval_days: Some(30), + }; + + let statuses = list_provider_statuses(&settings); + let tmdb = statuses + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb) + .expect("Expected TMDB provider to be registered"); + + assert!(tmdb.configured); +} + +#[test] +fn test_metadata_provider_id_rejects_legacy_musicbrainz_alias() { + let canonical: MetadataProviderId = serde_json::from_str("\"musicbrainz\"") + .expect("Expected canonical musicbrainz identifier to deserialize"); + let legacy = serde_json::from_str::("\"music_brainz\""); + + assert_eq!(canonical, MetadataProviderId::MusicBrainz); + assert!(legacy.is_err()); + assert_eq!( + serde_json::to_string(&MetadataProviderId::MusicBrainz).unwrap(), + "\"musicbrainz\"" + ); +} + +#[test] +fn test_metadata_provider_id_supports_tvdb() { + let provider: MetadataProviderId = + serde_json::from_str("\"tvdb\"").expect("Expected tvdb identifier to deserialize"); + + assert_eq!(provider, MetadataProviderId::Tvdb); + assert_eq!(serde_json::to_string(&provider).unwrap(), "\"tvdb\""); +} + +#[test] +fn test_metadata_provider_id_supports_trailerdb() { + let provider: MetadataProviderId = serde_json::from_str("\"trailerdb\"") + .expect("Expected trailerdb identifier to deserialize"); + + assert_eq!(provider, MetadataProviderId::TrailerDb); + assert_eq!(provider.as_storage_value(), "trailerdb"); + assert_eq!(serde_json::to_string(&provider).unwrap(), "\"trailerdb\""); +} + +#[test] +fn test_settings_persistence_clears_library_definitions() { + let mut settings = Settings::default(); + settings.media.libraries.push(MediaLibrarySettings { + name: "Movies".into(), + path: "C:/Media/Movies".into(), + paths: vec!["C:/Media/Movies".into()], + recursive: true, + kind: MediaLibraryKind::Movies, + scanner: Default::default(), + metadata_providers: vec![MetadataProviderId::Tmdb], + metadata_language_mode: koko::config::MediaLibraryMetadataLanguageMode::Auto, + metadata_languages: vec![], + allowed_user_ids: vec![], + }); + + let persisted = settings_for_persistence(&settings); + assert!(persisted.media.libraries.is_empty()); + + let persisted_yaml = settings_yaml_for_persistence(&settings).unwrap(); + for omitted in [ + "media:", + "libraries:", + "metadata:", + "server:", + "ffmpeg:", + ] { + assert!( + !persisted_yaml.contains(omitted), + "Expected persisted YAML to omit {omitted}, got:\n{persisted_yaml}" + ); + } + assert!(persisted_yaml.contains("general:")); +} + +#[test] +fn test_settings_persistence_includes_tvdb_provider_and_redacts_api_key() { + let mut settings = Settings::default(); + settings.metadata.providers = vec![MetadataProviderSettings { + id: MetadataProviderId::Tmdb, + enabled: true, + api_key: Some("tmdb-key".into()), + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: "en-US".into(), + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1_000, + }]; + + let persisted = settings_for_persistence(&settings); + let tvdb = persisted + .metadata + .providers + .iter() + .find(|provider| provider.id == MetadataProviderId::Tvdb) + .expect("Expected settings persistence to keep a TheTVDB provider entry"); + assert!(!tvdb.enabled); + assert_eq!(tvdb.api_key, None); + + let tmdb = persisted + .metadata + .providers + .iter() + .find(|provider| provider.id == MetadataProviderId::Tmdb) + .expect("Expected settings persistence to keep the TMDB provider entry"); + assert_eq!(tmdb.api_key, None); + assert!(tmdb.api_key_configured); +} + +#[test] +fn test_database_settings_round_trip_runtime_sections() { + use_sample_secret_store(); + + let mut conn = + diesel::SqliteConnection::establish(":memory:").expect("Expected in-memory SQLite"); + conn.batch_execute( + "CREATE TABLE app_settings (key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL,updated_at \ + BIGINT DEFAULT NULL);", + ) + .unwrap(); + + let mut settings = Settings::default(); + settings.server.port = 8181; + settings.ffmpeg = FfmpegSettings { + ffmpeg_path: "C:/Tools/ffmpeg.exe".into(), + ffprobe_path: "C:/Tools/ffprobe.exe".into(), + }; + settings.media.missing_item_auto_delete_days = Some(14); + settings.scheduled_tasks = ScheduledTasksSettings { + enabled: true, + window: ScheduledTaskWindowSettings { + start_time: "01:30".into(), + stop_time: "05:45".into(), + weekdays: vec![ + ScheduledTaskWeekday::Monday, + ScheduledTaskWeekday::Wednesday, + ScheduledTaskWeekday::Friday, + ], + }, + metadata_refresh: MetadataRefreshTaskSettings { enabled: false }, + trash_cleanup: TrashCleanupTaskSettings { + enabled: true, + missing_item_auto_delete_days: Some(14), + interval_days: 2, + }, + database_maintenance: DatabaseMaintenanceTaskSettings { + enabled: true, + interval_days: 3, + }, + }; + settings.metadata.providers = vec![MetadataProviderSettings { + id: MetadataProviderId::Tmdb, + enabled: true, + api_key: Some("tmdb-key".into()), + api_key_secret_ref: None, + api_key_configured: false, + clear_api_key: false, + language: "en-US".into(), + rate_limit_per_second: 4, + retry_attempts: 3, + retry_backoff_ms: 1_000, + }]; + + seed_database_settings(&mut conn, &settings).unwrap(); + let loaded = load_database_settings(&mut conn, &Settings::default()).unwrap(); + assert_eq!(loaded.server.port, 8181); + assert_eq!(loaded.ffmpeg.ffmpeg_path, "C:/Tools/ffmpeg.exe"); + assert_eq!(loaded.media.missing_item_auto_delete_days, None); + assert_eq!(loaded.scheduled_tasks.window.start_time, "01:30"); + assert_eq!( + loaded.scheduled_tasks.window.weekdays, + vec![ + ScheduledTaskWeekday::Monday, + ScheduledTaskWeekday::Wednesday, + ScheduledTaskWeekday::Friday, + ] + ); + assert!(!loaded.scheduled_tasks.metadata_refresh.enabled); + assert_eq!( + loaded + .scheduled_tasks + .trash_cleanup + .missing_item_auto_delete_days, + Some(14) + ); + assert_eq!(loaded.scheduled_tasks.trash_cleanup.interval_days, 2); + assert_eq!(loaded.scheduled_tasks.database_maintenance.interval_days, 3); + assert_eq!(loaded.metadata.providers[0].api_key, None); + assert!(loaded.metadata.providers[0].api_key_configured); + assert!(loaded.metadata.providers[0].api_key_secret_ref.is_some()); + assert!( + list_provider_statuses(&loaded.metadata) + .iter() + .any(|provider| provider.id == MetadataProviderId::Tmdb && provider.configured) + ); + let metadata_row = diesel::sql_query("SELECT value FROM app_settings WHERE key = 'metadata'") + .get_result::(&mut conn) + .unwrap(); + assert!(!metadata_row.value.contains("tmdb-key")); + + let mut updated = loaded.clone(); + updated.server.port = 8282; + updated.ffmpeg.ffmpeg_path = "D:/ffmpeg.exe".into(); + updated.media.missing_item_auto_delete_days = None; + updated.scheduled_tasks.window.start_time = "03:00".into(); + updated.scheduled_tasks.database_maintenance.enabled = false; + save_database_settings(&mut conn, &updated).unwrap(); + let reloaded = load_database_settings(&mut conn, &Settings::default()).unwrap(); + assert_eq!(reloaded.server.port, 8282); + assert_eq!(reloaded.ffmpeg.ffmpeg_path, "D:/ffmpeg.exe"); + assert_eq!(reloaded.media.missing_item_auto_delete_days, None); + assert_eq!(reloaded.scheduled_tasks.window.start_time, "03:00"); + assert!(!reloaded.scheduled_tasks.database_maintenance.enabled); +} + +#[test] +fn test_expected_artwork_cache_path_changes_when_url_changes() { + let cache_dir = std::path::Path::new("C:/tmp"); + let first = expected_artwork_cache_path( + "https://image.tmdb.org/t/p/w500/alpha.jpg", + cache_dir, + "tmdb_poster", + ); + let second = expected_artwork_cache_path( + "https://image.tmdb.org/t/p/w500/beta.jpg", + cache_dir, + "tmdb_poster", + ); + + assert_ne!(first, second); +} + +#[test] +fn test_persist_item_metadata_assets_clears_stale_provider_poster_when_url_missing() { + let temp_dir = std::env::temp_dir().join(format!( + "koko_metadata_asset_cleanup_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let data_dir = temp_dir.to_string_lossy().to_string(); + let snapshot = StoredMetadataSnapshot { + provider_id: MetadataProviderId::Tmdb, + external_id: "tv:1412:season:1:episode:1".into(), + media_type: Some("episode".into()), + title: Some("Pilot".into()), + overview: None, + artwork_url: None, + backdrop_url: None, + release_year: Some(2012), + locale_key: "en-US".into(), + provider_locale_key: Some("en-US".into()), + provider_payload_json: None, + }; + let item_dir = managed_metadata_asset_dir( + &data_dir, + snapshot.provider_id.clone(), + &snapshot.external_id, + snapshot.media_type.as_deref(), + &snapshot.locale_key, + ); + fs::create_dir_all(&item_dir).unwrap(); + fs::write(item_dir.join("tmdb_poster-aaaaaaaaaaaaaaaa.jpg"), b"stale").unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime + .block_on(persist_item_metadata_assets(&snapshot, 198, &data_dir)) + .unwrap(); + + assert!( + !item_dir.join("tmdb_poster-aaaaaaaaaaaaaaaa.jpg").exists(), + "Expected stale provider poster to be removed when no artwork URL is present" + ); + + fs::remove_dir_all(temp_dir).unwrap(); +} + +#[test] +fn test_managed_metadata_asset_dir_uses_locale_aware_sha256_uuid_path() { + let data_dir = "C:/koko-data"; + let english = managed_metadata_asset_dir( + data_dir, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "en-US", + ); + let spanish = managed_metadata_asset_dir( + data_dir, + MetadataProviderId::Tmdb, + "603", + Some("movie"), + "es-ES", + ); + + assert_ne!(english, spanish); + assert_eq!( + metadata_asset_uuid(MetadataProviderId::Tmdb, "603", "en-US"), + "tmdb:603:en-US" + ); + assert!( + english.to_string_lossy().contains("/metadata/movies/") + || english.to_string_lossy().contains("\\metadata\\movies\\") + ); + assert!(!english.to_string_lossy().contains(".bundle")); +} diff --git a/crates/server/tests/test_signal_handler.rs b/crates/server/tests/test_signal_handler.rs index c3e8cdce..dc5d868d 100644 --- a/crates/server/tests/test_signal_handler.rs +++ b/crates/server/tests/test_signal_handler.rs @@ -7,15 +7,69 @@ // standard imports use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{ + AtomicBool, + AtomicU32, + Ordering, +}; use std::thread; use std::time::Duration; use tokio::time::timeout; // local imports -use koko::signal_handler::{ShutdownCoordinator, ShutdownSignal}; +use koko::config::{ + Settings, + current_settings, + replace_current_settings, +}; +use koko::signal_handler::{ + ShutdownCoordinator, + ShutdownSignal, +}; use koko::web; +struct TestServerStateGuard { + original_settings: Settings, + test_dir: std::path::PathBuf, +} + +impl Drop for TestServerStateGuard { + fn drop(&mut self) { + replace_current_settings(self.original_settings.clone()); + let _ = std::fs::remove_dir_all(&self.test_dir); + } +} + +fn configure_isolated_web_server_settings() -> (TestServerStateGuard, String) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let test_dir = std::env::temp_dir().join(format!( + "koko_web_shutdown_{}_{}", + std::process::id(), + timestamp + )); + let data_dir = test_dir.join("data"); + std::fs::create_dir_all(&data_dir).expect("Failed to create isolated web server data dir"); + + let original_settings = current_settings(); + let mut settings = original_settings.clone(); + settings.general.data_dir = data_dir.to_string_lossy().to_string(); + settings.server.port = 0; + settings.server.use_https = false; + replace_current_settings(settings); + + let db_path = test_dir.join("koko.db").to_string_lossy().to_string(); + ( + TestServerStateGuard { + original_settings, + test_dir, + }, + db_path, + ) +} + mod shutdown_signal { use super::*; @@ -109,32 +163,45 @@ mod shutdown_signal { fn wait_with_timeout() { let signal = ShutdownSignal::new(); let signal_clone = signal.clone(); + let (waiting_tx, waiting_rx) = std::sync::mpsc::channel(); - // Spawn a thread that will set shutdown after a short delay - thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - signal_clone.shutdown(); - }); + // Use a custom wait implementation that can timeout for testing. + let handle = thread::spawn(move || { + let start = std::time::Instant::now(); + let mut waited = false; + let mut waiting_sent = false; - // Test wait with a reasonable timeout - let start = std::time::Instant::now(); + while !signal_clone.is_shutdown() { + waited = true; + if !waiting_sent { + waiting_tx + .send(()) + .expect("Should notify that wait loop started"); + waiting_sent = true; + } - // Use a custom wait implementation that can timeout for testing - let mut waited = false; - while !signal.is_shutdown() { - thread::sleep(Duration::from_millis(10)); - if start.elapsed() > Duration::from_millis(200) { - break; + if start.elapsed() > Duration::from_secs(5) { + return Err("Timed out waiting for shutdown signal"); + } + + thread::sleep(Duration::from_millis(10)); } - waited = true; - } + + Ok(waited) + }); + + waiting_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait loop should start before shutdown"); + signal.shutdown(); + + let waited = handle + .join() + .expect("Wait thread should complete without panicking") + .expect("Should not have timed out"); assert!(waited, "Should have waited for shutdown signal"); assert!(signal.is_shutdown(), "Signal should be shutdown after wait"); - assert!( - start.elapsed() < Duration::from_millis(200), - "Should not have timed out" - ); } #[test] @@ -238,28 +305,37 @@ mod shutdown_signal { fn wait_functionality() { let signal = ShutdownSignal::new(); let signal_clone = signal.clone(); + let (waiting_tx, waiting_rx) = std::sync::mpsc::channel(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); - // Spawn a thread that will signal shutdown after a delay let handle = thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - signal_clone.shutdown(); + waiting_tx + .send(()) + .expect("Should notify that wait is about to start"); + signal_clone.wait(); + completed_tx + .send(signal_clone.is_shutdown()) + .expect("Should notify that wait completed"); }); - // Test the wait method - this should return once shutdown is signaled - let start = std::time::Instant::now(); - signal.wait(); - let elapsed = start.elapsed(); - - // Should have waited for about 50ms + waiting_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait thread should start"); assert!( - elapsed >= Duration::from_millis(40), - "Should have waited for shutdown signal" + matches!( + completed_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ), + "Wait should block until shutdown is signaled" ); + + signal.shutdown(); assert!( - elapsed < Duration::from_millis(300), - "Should not have waited too long" + completed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait should return after shutdown is signaled"), + "Signal should be shutdown after wait" ); - assert!(signal.is_shutdown(), "Signal should be shutdown after wait"); handle.join().unwrap(); } @@ -271,16 +347,23 @@ mod shutdown_signal { // Signal shutdown first signal.shutdown(); - // Then call wait - should return immediately - let start = std::time::Instant::now(); - signal.wait(); - let elapsed = start.elapsed(); + let signal_clone = signal.clone(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + + let handle = thread::spawn(move || { + signal_clone.wait(); + completed_tx + .send(signal_clone.is_shutdown()) + .expect("Should notify that wait completed"); + }); - // Should return almost immediately since signal is already shutdown assert!( - elapsed < Duration::from_millis(50), - "Wait should return immediately for already shutdown signal" + completed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("Wait should return for already shutdown signal"), + "Signal should remain shutdown after wait" ); + handle.join().unwrap(); } } @@ -521,17 +604,7 @@ mod shutdown_coordinator { } #[test] - #[should_panic(expected = "Failed to create tokio runtime")] - fn async_thread_runtime_creation_failure() { - // This test is tricky to trigger in practice, but we can document it - // The panic path occurs when tokio runtime creation fails - // In normal circumstances this should never happen, but the panic is there for safety - - // Since we can't easily mock runtime creation failure, we'll create a separate test - // that documents this behavior. The actual panic line will be covered when/if - // runtime creation actually fails in extreme circumstances. - - // For now, let's verify that normal async thread creation works fine + fn async_thread_runtime_creation_success() { let mut coordinator = create_test_coordinator(); coordinator.register_async_thread("normal-async", |_| async move { @@ -540,9 +613,7 @@ mod shutdown_coordinator { coordinator.wait_for_completion(); - // If we reach here, runtime creation worked fine - // The panic path is for extreme error conditions that are hard to reproduce in tests - panic!("Failed to create tokio runtime for test_panic_scenario"); + // If we reach here, runtime creation worked fine. } #[test] @@ -625,25 +696,40 @@ mod integration { #[tokio::test] async fn web_server_shutdown_signal_handling() { + let (_test_server_state_guard, db_path) = configure_isolated_web_server_settings(); let shutdown_signal = ShutdownSignal::new(); let shutdown_signal_clone = shutdown_signal.clone(); + let (launched_tx, launched_rx) = tokio::sync::oneshot::channel(); + let launch_notifier = Arc::new(std::sync::Mutex::new(Some(launched_tx))); + let rocket = web::rocket_with_db_path(Some(db_path)).attach( + rocket::fairing::AdHoc::on_liftoff("Notify test launch", move |_| { + let launch_notifier = Arc::clone(&launch_notifier); + Box::pin(async move { + if let Some(launched_tx) = launch_notifier.lock().unwrap().take() { + let _ = launched_tx.send(()); + } + }) + }), + ); // Start web server in background let web_handle = tokio::spawn(async move { - web::launch_with_shutdown(shutdown_signal_clone).await; + web::launch_rocket_with_shutdown(rocket, shutdown_signal_clone).await; }); - // Give the server a moment to start - tokio::time::sleep(Duration::from_millis(100)).await; + timeout(Duration::from_secs(30), launched_rx) + .await + .expect("Web server should launch within 30 seconds") + .expect("Web server task should not exit before launch"); // Signal shutdown shutdown_signal.shutdown(); // Web server should shut down within a reasonable time - let result = timeout(Duration::from_secs(2), web_handle).await; + let result = timeout(Duration::from_secs(10), web_handle).await; assert!( result.is_ok(), - "Web server should shut down within 2 seconds" + "Web server should shut down within 10 seconds" ); } diff --git a/crates/server/tests/test_tray.rs b/crates/server/tests/test_tray.rs index d400c807..5bccdc98 100644 --- a/crates/server/tests/test_tray.rs +++ b/crates/server/tests/test_tray.rs @@ -1,36 +1,53 @@ +#![cfg(feature = "tray")] + // standard imports use std::path::Path; -use std::thread; -use std::time::Duration; -/// Because `launch()` runs an event loop indefinitely, this test spawns a thread to -/// call `launch()`, waits a brief moment, then ends the test. It mainly verifies that the -/// function can be invoked without immediate panics. Adjust as needed for full integration testing. +/// Tests that an existing source icon can be decoded for the tray. #[test] -fn test_launch_does_not_panic_immediately() { - // We run this in a separate thread because `launch()` never returns under normal circumstances. - let handle = thread::spawn(|| { - koko::tray::launch(); - }); - - // Wait a short moment to see if a panic happens right away. - thread::sleep(Duration::from_secs(1)); - - // We don't join the thread because `launch()` won't return in this example, - // but dropping the handle will end the spawned thread here. - drop(handle); +fn test_load_icon_source_icon() { + use koko::tray::load_icon; + + let icon_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("assets") + .join("icon.ico"); + + let _icon = load_icon(&icon_path); +} + +/// Tests that the tray event loop honors shutdown without creating a long-running test process. +#[cfg(target_os = "windows")] +#[test] +fn test_launch_with_shutdown_exits() { + let shutdown_signal = koko::signal_handler::ShutdownSignal::new(); + shutdown_signal.shutdown(); + + koko::tray::launch_with_shutdown(shutdown_signal); } /// Tests the `load_icon` function with a path that does not exist. /// We expect a panic, because the code calls `image::open` /// and it should fail on a non-existent file. #[test] -#[should_panic(expected = "Failed to open icon path")] fn test_load_icon_non_existent_path_panics() { use koko::tray::load_icon; let non_existent_path = Path::new("non_existent_file.ico"); // This should panic based on the logic within `load_icon`. - let _icon = load_icon(non_existent_path); + let result = std::panic::catch_unwind(|| load_icon(non_existent_path)); + let panic = result.expect_err("Missing icon path should panic"); + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + + assert!( + message.contains("Failed to open icon path"), + "unexpected panic message: {}", + message + ); } diff --git a/crates/server/tests/test_utils.rs b/crates/server/tests/test_utils.rs index c6fa9d62..8f71f573 100644 --- a/crates/server/tests/test_utils.rs +++ b/crates/server/tests/test_utils.rs @@ -1,10 +1,18 @@ //! Shared test utilities to eliminate code duplication across test files. // standard imports -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{ + AtomicU64, + Ordering, +}; // lib imports -use rocket::http::{ContentType, Header, Status}; +use once_cell::sync::Lazy; +use rocket::http::{ + ContentType, + Header, + Status, +}; use rocket::local::asynchronous::Client; use serde_json::Value; @@ -13,6 +21,8 @@ use koko::web; // Global counter to ensure unique database files across all tests static GLOBAL_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); +static TEST_CLIENT_CREATION_LOCK: Lazy> = + Lazy::new(|| std::sync::Mutex::new(())); /// Enhanced test response structure with headers pub struct TestResponse { @@ -23,6 +33,8 @@ pub struct TestResponse { /// Create a test client with an isolated database pub async fn create_test_client(prefix: Option<&str>) -> Client { + let _lock = TEST_CLIENT_CREATION_LOCK.lock().unwrap(); + // Set the test environment first use koko::globals::CURRENT_ENV; CURRENT_ENV.store(1, Ordering::SeqCst); @@ -43,11 +55,18 @@ pub async fn create_test_client(prefix: Option<&str>) -> Client { // Create the full database path let db_path = format!("./test_data/{}", db_name); + let settings_path = format!("./test_data/{}_{}_{}.yml", prefix, test_id, timestamp); // Remove the database file if it exists from a previous run if std::path::Path::new(&db_path).exists() { std::fs::remove_file(&db_path).ok(); } + if std::path::Path::new(&settings_path).exists() { + std::fs::remove_file(&settings_path).ok(); + } + + // Persist settings to a test-scoped file instead of the user's real config path. + std::env::set_var("KOKO_SETTINGS_PATH", &settings_path); // Create a new rocket instance with the unique database path let rocket = web::rocket_with_db_path(Some(db_path)); @@ -73,10 +92,7 @@ pub async fn make_request( let client = match client { Some(c) => c, None => { - let rocket = web::rocket(); - owned_client = Client::tracked(rocket) - .await - .expect("Failed to launch web server"); + owned_client = create_test_client(Some("request")).await; &owned_client } }; diff --git a/crates/server/tests/test_web/mod.rs b/crates/server/tests/test_web/mod.rs index c92bf0cb..87937ac2 100644 --- a/crates/server/tests/test_web/mod.rs +++ b/crates/server/tests/test_web/mod.rs @@ -47,7 +47,7 @@ async fn test_non_existent_route() { "/non-existent", None, None, - Some(Status::NotFound), + Some(Status::Ok), Some(false), ) .await; @@ -56,7 +56,7 @@ async fn test_non_existent_route() { #[tokio::test] async fn test_web_server_rocket_build() { // Test that we can build a rocket instance without errors - let rocket = web::rocket(); + let rocket = web::rocket_with_db_path(Some(":memory:".to_string())); assert!( rocket.ignite().await.is_ok(), "Rocket should ignite successfully" diff --git a/crates/server/tests/test_web/routes/common.rs b/crates/server/tests/test_web/routes/common.rs index 8c37d376..d33020ce 100644 --- a/crates/server/tests/test_web/routes/common.rs +++ b/crates/server/tests/test_web/routes/common.rs @@ -2,12 +2,48 @@ use rocket::http::Status; // test imports -use crate::test_utils::{create_test_client, make_request}; +use crate::test_utils::{ + create_test_client, + make_request, +}; #[rocket::async_test] async fn test_root_route() { let client = create_test_client(Some("common_routes")).await; + let response = make_request( + Some(&client), + "get", + "/", + None, + None, + Some(Status::Ok), + Some(true), + ) + .await; + + let content_type = response + .headers + .iter() + .find(|header| header.name().as_str().eq_ignore_ascii_case("content-type")) + .map(|header| header.value().to_string()) + .unwrap_or_default(); + + assert!( + content_type.contains("text/html"), + "Expected HTML content type for the root route, got: {}", + content_type + ); + assert!( + response.body.contains(" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..5b5ff889 --- /dev/null +++ b/deny.toml @@ -0,0 +1,43 @@ +[licenses] +include-dev = true +unused-allowed-license = "allow" + +# cargo-deny's license check is allow-list based: every license not listed here +# or in a scoped exception is rejected. This intentionally excludes copyleft, +# proprietary, and custom license terms such as GPL-3.0, AGPL-3.0, LGPL, and +# non-SPDX "Custom License" or "Proprietary" declarations. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "Unlicense", + "Zlib", +] + +exceptions = [ + { crate = "webpki-root-certs", allow = ["CDLA-Permissive-2.0"] }, +] + +[[licenses.clarify]] +crate = "cfg_block" +expression = "Apache-2.0" +license-files = [ + { path = "LICENSE", hash = 0xc9663a1e }, +] + +[[licenses.clarify]] +crate = "dlopen2_derive" +expression = "MIT" +license-files = [ + { path = "LICENSE", hash = 0xea2c3d1e }, +] + +[licenses.private] +ignore = true diff --git a/docs/METADATA_PROVIDER_TEMPLATE.md b/docs/METADATA_PROVIDER_TEMPLATE.md new file mode 100644 index 00000000..ce5ac93d --- /dev/null +++ b/docs/METADATA_PROVIDER_TEMPLATE.md @@ -0,0 +1,113 @@ +# Metadata Provider Template + +Metadata providers should keep provider-specific API calls, locale mapping, payload parsing, and enrichment inside `crates/server/src/metadata/providers/.rs`. +`crates/server/src/metadata/mod.rs` should only orchestrate provider calls and persist normalized Koko structures. + +## Provider Roles + +- `MetadataProviderRole::Primary`: can search and fetch canonical metadata for library items. +- `MetadataProviderRole::Secondary`: extends one or more primary providers with extra metadata, such as theme songs or trailers. + +Declare supported library kinds in the descriptor with `supported_kinds`. Secondary providers should also set `extends_provider_ids`. + +## Required Steps + +1. Add a `MetadataProviderId` variant in `crates/server/src/config.rs`. +2. Create `crates/server/src/metadata/providers/.rs`. +3. Implement a provider wrapper in `crates/server/src/metadata/providers/mod.rs`. +4. Register the wrapper in `MetadataRegistry::new()`. +5. Return normalized Koko data through `StoredMetadataSnapshot` and `ProviderMetadataDetails`. +6. Add provider-local tests for payload parsing, locale mapping, and edge cases. + +## Primary Provider Skeleton + +```rust +use crate::config::{MediaLibraryKind, MetadataProviderId, MetadataSettings}; +use crate::metadata::{ + MetadataItemKind, MetadataProviderDescriptor, MetadataProviderRole, MetadataSearchResult, + ProviderMetadataDetails, StoredMetadataSnapshot, +}; + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::Example, + display_name: "Example".into(), + description: "Primary metadata provider for ...".into(), + supported_kinds: vec![MediaLibraryKind::Movies, MediaLibraryKind::Shows], + requires_api_key: true, + implemented: true, + role: MetadataProviderRole::Primary, + extends_provider_ids: Vec::new(), + attribution_text: "Metadata provided by Example.".into(), + attribution_url: "https://example.test/".into(), + logo_light_url: None, + logo_dark_url: None, + } +} + +pub(crate) fn metadata_item_kind(media_type: Option<&str>) -> MetadataItemKind { + match media_type.unwrap_or_default().trim() { + "movie" => MetadataItemKind::Movie, + "series" => MetadataItemKind::Show, + _ => MetadataItemKind::Item, + } +} + +pub(crate) async fn search( + settings: &MetadataSettings, + query: &str, + media_type: Option<&str>, +) -> Result, String> { + // Read this provider's settings, call the provider API, and map results into MetadataSearchResult. + // Use media_type to choose or filter provider-specific search types. + todo!() +} + +pub(crate) async fn fetch_snapshot( + settings: &MetadataSettings, + external_id: &str, + media_type: &str, +) -> Result { + // Fetch provider payload and map core item fields into StoredMetadataSnapshot. + // Keep raw payload only for diagnostics/re-normalization, not for serving UI data directly. + todo!() +} + +pub(crate) fn metadata_details(snapshot: &StoredMetadataSnapshot) -> ProviderMetadataDetails { + // Parse the provider payload here and return database-ready extras: + // external_ids, tagline, logo_url, genres, rating, content_rating, trailers, collections, people. + ProviderMetadataDetails::default() +} +``` + +## Secondary Provider Skeleton + +```rust +use crate::config::{MediaLibraryKind, MetadataProviderId}; +use crate::metadata::{MetadataProviderDescriptor, MetadataProviderRole}; + +pub(crate) fn descriptor() -> MetadataProviderDescriptor { + MetadataProviderDescriptor { + id: MetadataProviderId::ExampleSecondary, + display_name: "Example Secondary".into(), + description: "Secondary provider that enriches primary metadata.".into(), + supported_kinds: vec![MediaLibraryKind::Movies, MediaLibraryKind::Shows], + requires_api_key: false, + implemented: true, + role: MetadataProviderRole::Secondary, + extends_provider_ids: vec![MetadataProviderId::Tmdb], + attribution_text: "Extra metadata provided by Example Secondary.".into(), + attribution_url: "https://example.test/".into(), + logo_light_url: None, + logo_dark_url: None, + } +} +``` + +## Rules Of Thumb + +- Do not add provider URLs, locale maps, payload parsing, or provider ID branches to `metadata/mod.rs`. +- Do not serve UI metadata by reading cached provider JSON. Normalize provider fields into database columns during refresh/upsert. +- Put useful cross-provider IDs in `ProviderMetadataDetails.external_ids`, not in provider JSON-only fallbacks. +- Keep provider payload JSON optional and diagnostic. If a field is useful to Koko, add it to `ProviderMetadataDetails` or a dedicated database structure. +- Provider modules can use shared Koko helpers for caching and storage paths, but they own the provider-specific interpretation of payloads. diff --git a/docs/MOVIE_NAMING.md b/docs/MOVIE_NAMING.md new file mode 100644 index 00000000..822a3d54 --- /dev/null +++ b/docs/MOVIE_NAMING.md @@ -0,0 +1,164 @@ +# Movie naming guidelines + +Koko matches movie files more reliably when each media type lives under its own top-level folder and movie files follow +a predictable naming pattern. + +## Recommended folder layout + +Keep movies separate from shows, music, books, and photos. + +```text +/Media + /Books + /Movies + /Music + /Photos + /TV Shows +``` + +When you create a movie library in Koko, point it at the movie root such as `Media/Movies`. + +## Preferred movie layout + +The most reliable option is one folder per movie: + +```text +/Movies + /Movie Title (2024) + Movie Title (2024).mkv +``` + +This layout works well when you also keep artwork, subtitles, or alternate editions alongside the movie. + +Examples: + +```text +/Movies + /Avatar (2009) + Avatar (2009).mkv + /Batman Begins (2005) + Batman Begins (2005).mp4 + Batman Begins (2005).en.srt + poster.jpg +``` + +## Flat movie layout + +Koko also supports movies stored directly inside the library root: + +```text +/Movies + Avatar (2009).mkv + Batman Begins (2005).mp4 +``` + +This can work well for smaller libraries, but folder-per-movie is still recommended when you have local assets. + +## Optional metadata tags in braces + +You can include helpful tags in curly braces or square brackets after the movie title and year. +Multiple tags can be split across multiple bracket groups, or combined in one group with `:`. + +Supported examples: + +```text +Batman Begins (2005) {tmdb-272}.mp4 +Batman Begins (2005) {imdb-tt0372784}.mp4 +Batman Begins (2005) [tmdb-272:tvdb-321].mp4 +Batman Begins (2005) [tmdb-272] [edition-Blu-ray].mp4 +Blade Runner (1982) {edition-Final Cut}.mkv +``` + +Useful tag forms: + +- `{tmdb-272}` +- `{tvdb-321}` +- `{imdb-tt0372784}` +- `{edition-Director's Cut}` +- `{edition-Extended}` + +Koko strips these tags before title matching and uses provider identifiers as hints where possible. +TMDB matching can use direct `tmdb` IDs and can resolve `tvdb` or `imdb` IDs through TMDB's external-id lookup. +TheTVDB matching can use direct `tvdb` IDs. + +## Legacy and quality suffixes + +Koko also accepts legacy file names where the format is separated by a dash: + +```text +Beyond The Sky (2018) - Bluray-1080p.mkv +Movie Title (2024) - 2160p WEB-DL x265.mkv +``` + +The format suffix is stripped from the display title and metadata search title. You may also put the format in brackets: + +```text +Beyond The Sky (2018) [Bluray-1080p].mkv +Movie Title (2024) [2160p:WEB-DL:x265].mkv +``` + +For titles that normally contain a colon but are stored on Windows, use a dash before the subtitle: + +```text +Top Gun- Maverick (2022) - 1080p.mkv +``` + +Koko displays this as `Top Gun: Maverick`. + +## Multiple editions + +If you keep more than one edition of the same movie, include the edition tag in the folder name, filename, or both. + +```text +/Movies + /Blade Runner (1982) {edition-Director's Cut} + Blade Runner (1982) {edition-Director's Cut}.mp4 + /Blade Runner (1982) {edition-Final Cut} + Blade Runner (1982) {edition-Final Cut}.mkv +``` + +## Split movie files + +Split files are best kept in their own movie folder. + +Koko recognizes common part suffixes such as: + +- `cd1`, `cd2` +- `disc1`, `disc2` +- `disk1`, `disk2` +- `dvd1`, `dvd2` +- `part1`, `part2` +- `pt1`, `pt2` + +Example: + +```text +/Movies + /The Dark Knight (2008) + The Dark Knight (2008) - pt1.mp4 + The Dark Knight (2008) - pt2.mp4 +``` + +## General naming tips + +- Include the release year whenever possible. +- Prefer spaces over noisy scene-release formatting. +- Avoid leaving quality, codec, or release-group tags in the display title when you can place them elsewhere. +- Keep subtitle, poster, and backdrop files in the same movie folder when you use local assets. +- Keep folder and filename titles aligned to reduce ambiguous matches. + +## Examples Koko matches well + +```text +/Movies + /Alien (1979) + Alien (1979).mkv + +/Movies + /Dune Part Two (2024) {tmdb-693134} + Dune Part Two (2024) {tmdb-693134}.mkv + +/Movies + /Mad Max Fury Road (2015) {edition-Black and Chrome} + Mad Max Fury Road (2015) {edition-Black and Chrome}.mkv +``` diff --git a/docs/README.md b/docs/README.md index b9bb6aa5..6c7fad1e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,13 +28,40 @@ Koko is a (WIP) self-hosted media server written in Rust. At this point in time and you **SHOULD NOT** use this for any purpose. I don't know what I am doing and the code is probably terrible. This is also **NOT** a functioning media server yet. Once it is, I will update this README. +Current product direction highlights: + +- The browser UI is targeting a Kodi/Plex-style media-first experience. +- TheMovieDB is the first planned online metadata source for movies and TV. +- Metadata providers should stay pluggable so Koko can add more sources over time. +- Koko currently assumes external `ffmpeg` and `ffprobe` executables by default to keep the licensing path clearer for source-available distribution. + If you are interested in this project, please leave a star and watch the repository for updates. If you would like to contribute, please reach out on our [discord](https://app.lizardbyte.dev/discord) server. ## ⚙️ Configuration -Koko uses a YAML configuration file to set up the server. +Koko uses a YAML configuration file for core server settings. + +Media libraries are stored in the application database instead of the YAML file. The browser settings UI edits +server settings in YAML and library definitions in the database. + +Database schema changes are managed only through Diesel SQL migrations in `crates/server/sql/migrations`. +Koko has not had a release yet, so the current database starts from one consolidated initial schema migration. +Migration directories use an opaque hash-like revision prefix, and runtime execution order is defined by +`SQLITE_MIGRATION_ORDER` in `crates/server/src/db/mod.rs` instead of hash lexicographic order. Future schema or data +changes should be added as new migration files instead of Rust startup repair code. + +Create a new migration from the repository root with: + +```console +cargo new-migration add_media_flags +``` + +If the name is omitted, the command prompts for it. The command generates a unique revision, creates `up.sql` and +`down.sql`, appends the revision to `SQLITE_MIGRATION_ORDER`, and runs `cargo +nightly fmt`. + +For movie library naming guidance, see [Movie naming guidelines](./MOVIE_NAMING.md). The file must be named `settings.yml` and be placed in the following location, depending on your OS. @@ -59,6 +86,18 @@ server: cert_path: 'cert.pem' key_path: 'key.pem' use_custom_certs: false + +ffmpeg: + strategy: 'external_binaries' + ffmpeg_path: 'ffmpeg' + ffprobe_path: 'ffprobe' + +metadata: + providers: + - id: 'tmdb' + enabled: true + api_key: '' + language: 'en-US' ``` ## 📝 TODO @@ -107,6 +146,7 @@ This list is not all-inclusive, and just meant to be a very high level for the i - [ ] Media Player - [ ] User Management - [ ] Legal/Licensing info on dependencies +- [ ] Plugin system - [ ] User Documentation - [x] Publish docs to ReadTheDocs - [ ] Create Gurubase and enable readme badge diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..2cf14535 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,199 @@ +# Koko roadmap + +This file tracks the staged path from the current proof-of-concept into a complete self-hosted media platform. + +## Product direction + +Koko should grow as a single-repo Rust media platform with a strong shared core: + +- Rust-first server and shared contracts +- FFmpeg-backed media inspection, transcoding, and packaging +- External-FFmpeg-first licensing posture, with an abstraction layer that keeps future embedded-library support possible if licensing allows +- TMDB-first metadata for movies and TV, with a provider model that keeps additional sources pluggable and user-selectable over time +- Browser client first +- Kodi/Plex-inspired browse and playback UX +- Desktop packaging next for Windows, Linux, and macOS +- Mobile and TV clients after the server and browser APIs stabilize +- Roku and Xbox after the streaming formats, remote UX, and compatibility story are mature + +## Client priority order + +1. Web browser +2. Windows, Linux, and macOS +3. Android, including Android TV +4. iOS, including Apple TV +5. Roku +6. Xbox One and Xbox Series + +## Repo direction + +The repo should remain the main home for the server and as many clients as practical. + +Planned top-level crate layout: + +- `crates/server`: Rust server, APIs, jobs, scanner, transcoding, auth, persistence +- `crates/common`: shared Rust domain models, API contracts, playback profiles, and utilities +- `crates/client-web`: browser UI and shared web assets +- `crates/client-desktop`: desktop shell and native integrations +- `crates/client-android`: Android and Android TV client +- `crates/client-ios`: iOS and Apple TV client +- `crates/client-roku`: Roku client if practical in-repo +- `crates/client-xbox`: Xbox-focused client shell or shared console client assets if feasible + +## Delivery stages + +### Stage 1: Server core and browser-first API foundation + +Status: In progress + +Goal: make the server useful enough that a browser client can browse libraries, inspect media, authenticate, and start playback without redesigning the backend. + +Checklist: + +- [x] Create a roadmap and tracking file +- [x] Add media library configuration and discovery foundation +- [x] Add FFmpeg and ffprobe capability detection foundation +- [x] Add versioned server/media discovery endpoints for future clients +- [x] Add persistent media-library data model and migrations +- [x] Add filesystem scanner jobs and incremental rescans +- [ ] Add metadata extraction and artwork generation + - [x] Add ffprobe-backed metadata extraction baseline + - [x] Add pluggable metadata-provider registry and persistence baseline +- [ ] Add media item, collection, and search APIs + - [x] Add media item detail and search API baseline + - [x] Add metadata provider and item metadata status API baseline +- [ ] Add stream manifest and direct-play decision APIs +- [ ] Add FFmpeg transcoding sessions and job management +- [ ] Add background task coordination, progress, and cancellation +- [ ] Add API docs and stable contract review for browser client work + +### Stage 2: Browser client + +Status: In progress + +Goal: ship the first real user-facing client. + +Design target: a Kodi/Plex-inspired browsing experience with shelves, hero areas, poster art, and a media-first detail layout. + +Checklist: + +- [x] Create `crates/client-web` +- [ ] Implement login and session handling +- [ ] Implement library browsing and search + - [x] Add Kodi/Plex-inspired shelf and poster card baseline +- [ ] Implement item details, artwork, and playback UI + - [x] Add metadata provider status and linked metadata detail baseline +- [ ] Implement admin pages for libraries, users, and transcoding settings +- [ ] Add quality selection and playback error reporting + +### Stage 3: Desktop app for Windows, Linux, and macOS + +Status: Planned + +Goal: deliver a packaged desktop experience by reusing browser-client functionality where possible. + +Checklist: + +- [ ] Define desktop shell architecture +- [ ] Reuse browser UI where practical +- [ ] Add native windowing, tray, deep links, and auto-start support +- [ ] Add packaging and signing workflows per OS + +### Stage 4: Android and Android TV + +Status: Planned + +Goal: mobile and TV playback with touch and remote-friendly UX. + +Checklist: + +- [ ] Define shared playback and authentication contracts in `crates/common` +- [ ] Build Android phone and tablet UX +- [ ] Build Android TV ten-foot UX +- [ ] Add offline sync and mobile network-aware playback policy + +### Stage 5: iOS and Apple TV + +Status: Planned + +Goal: parity with Android while respecting Apple platform constraints. + +Checklist: + +- [ ] Implement iOS playback and navigation +- [ ] Implement tvOS experience +- [ ] Add platform-safe packaging, signing, and distribution flow + +### Stage 6: Roku + +Status: Planned + +Goal: focused living-room experience once the server and stream formats are stable. + +Checklist: + +- [ ] Confirm in-repo feasibility and toolchain approach +- [ ] Implement remote-first browse and playback flows +- [ ] Validate supported codecs, subtitle handling, and fallback transcoding + +### Stage 7: Xbox One and Xbox Series + +Status: Planned + +Goal: deliver console playback after the ten-foot UX and streaming stack are mature. + +Checklist: + +- [ ] Confirm platform delivery approach and repo fit +- [ ] Implement controller-friendly browse and playback flows +- [ ] Validate console-specific playback constraints and packaging requirements + +## Cross-cutting workstreams + +These run across multiple stages: + +- [ ] Security hardening, secrets handling, and audit logging +- [ ] Observability, metrics, tracing, and structured diagnostics +- [ ] Performance baselines for scan, metadata, and transcoding workflows +- [ ] Internationalization and accessibility +- [ ] CI, release automation, packaging, and update channels +- [ ] Dependency license and supply-chain review +- [ ] User documentation and deployment guides + +## Metadata strategy + +- TheMovieDB is the first planned online metadata provider for movies and TV. +- The metadata system should stay provider-agnostic so future sources for music, books, photos, and local sidecar metadata can be added without redesigning the core media catalog. +- Users should eventually be able to enable providers, set priority order, and choose which providers apply to each library type. + +## FFmpeg strategy + +- The current implementation path assumes external `ffmpeg` and `ffprobe` executables by default. +- This keeps the licensing path clearer for a source-available distribution model while still letting Koko use FFmpeg capabilities. +- The server architecture should keep a clean transcoding abstraction so embedded FFmpeg libraries remain a future option if licensing and distribution requirements are compatible. + +## Current sprint + +The current sprint is focused on the first shippable Stage 1 slice: + +- media-library configuration model +- library discovery summaries +- FFmpeg capability detection +- versioned discovery endpoints for future clients +- persistent media-library catalog and file inventory baseline +- incremental rescans with stable media item IDs +- ffprobe-backed metadata persistence for audio and video files +- metadata-provider registry and item metadata link baseline +- browser-oriented item, detail, and search APIs +- initial `crates/client-web` scaffold consuming Stage 1 APIs +- Kodi/Plex-inspired poster and metadata detail baseline in `crates/client-web` + +## Exit criteria for Stage 1 + +Stage 1 is complete when: + +- the server can scan configured libraries and persist results +- metadata extraction is reliable enough to drive a browser UI +- direct play versus transcode decisions are available through stable APIs +- FFmpeg-backed playback sessions can be started, monitored, and stopped +- the browser client can browse and play media using supported server APIs diff --git a/docs/SHOW_NAMING.md b/docs/SHOW_NAMING.md new file mode 100644 index 00000000..b20c199e --- /dev/null +++ b/docs/SHOW_NAMING.md @@ -0,0 +1,58 @@ +# Show naming guidelines + +Koko matches shows most reliably when each show has its own folder, with seasons grouped beneath it. + +## Recommended layout + +```text +/TV Shows + /Show Title + /Season 1 + Show Title - S01E01 - Pilot.mkv + Show Title - S01E02 - The Next Episode.mkv +``` + +Koko recognizes common season and episode forms: + +- `Season 1` +- `Series 1` +- `S01E01` +- `1x01` +- `E01` + +## Provider tags + +Show folders may include provider tags in braces or square brackets: + +```text +/TV Shows + /Example Show (2020) [tmdb-12345:tvdb-67890] + /Season 1 + Example Show - S01E01 - Pilot.mkv +``` + +Use these when a title is ambiguous or a provider has multiple records for the same name. + +Useful tag forms: + +- `{tmdb-12345}` +- `{tvdb-67890}` +- `{imdb-tt1234567}` + +## Episode titles + +Episode titles are optional, but helpful for browsing before external metadata is linked. + +```text +Show Title - S02E03 - Episode Name.mkv +Show Title - 2x03 - Episode Name.mkv +``` + +If an episode title is missing, Koko falls back to the cleaned filename until provider metadata is linked. + +## General tips + +- Keep one show per folder. +- Keep season folders consistent within a show. +- Include season and episode numbers in every episode filename. +- Put provider IDs on the show folder rather than every episode unless an episode needs a special match. diff --git a/docs/known-issues.md b/docs/known-issues.md new file mode 100644 index 00000000..163c370a --- /dev/null +++ b/docs/known-issues.md @@ -0,0 +1,137 @@ +- [x] web-ui: player does not resume for direct play items, it starts over from the beginning (though the progress bar looks like it's resuming from the old position)... it is possible actually to resume? probably start playback and auto seek to the position? +- [x] web-ui: home-preview if highlighting a season or episode in recently added row, it should show the info from the show in the preview since seasons and episodes do not have this info +- [x] web-ui: when viewing a person, don't show seasons and episodes they are in by default, just show the top level show... then if the show is highlighted show the seasons in like an expanding tray... and then if a season is highlighted, show the episodes they are in another tray +- [x] web-ui: when viewing a collection it should open a dedicated page like a tv show page or season page instead of what it's doing now which is just like a filtered library view +- [x] web-ui: make playlists and genres load a page, just like collections now +- [x] web-ui: on the collection tab (and probably playlist/genre) the hero should be based on the first item in the collection, and then update when highlighting a different item +- [x] web-ui: on the collection page, it should look just like the library page and show the collection poster for the card with the collection title below +- [x] web-ui: when viewing an item, show other items which are in the same collection as this item, if the current item is in a collection... if it's in multiple collections, have rails for each one... if this is the only item in the collection, then don't show a rail for it +- [x] web-ui: refreshing metadata shows spinner on all items, even if they are already finished +- [x] web-ui: make background for spinner smaller so it is not ~2x bigger than the spinner, but only slightly bigger... if needed make spinner bigger to fix +- [x] web-ui: general issue where when data comes in the page almost reloads or rebuilds the dom and then resets the position where the user has scrolled to for example... especially noticeable on library views with rails like recommended and recently added + +- [x] add a "scanner" module, which is responsible for file scanning, we should have different types of scanners + - [x] movies scanner + - [x] tv shows scanner + - [o] music scanner + - [o] photos scanner + - [o] books scanner +- [x] store media file hash in database to know when a file has changed, when it changes we should re-run ffprobe stuff +- [x] the scanner should be responsible for deciding what to store as the hash value +- [x] have a base scanner (directory) module with the other modules as their own rs files... hashing can be common from the base scanner, but future scanner may use different hashing method +- [x] each library should have a scanner option, and default to the obvious scanner for each library type... this should have no affect on the metadata provider other than what the scanner puts into the db for the initial searching (I think) + +- [x] scheduled-tasks: add a scheduled tasks module, which is responsible for running scheduled tasks, we should have different types of tasks + - [x] have option to set when scheduled tasks start and stop, e.g. only run between 2am and 6am, could also set the days of the week + - [x] metadata refresh task, which will refresh metadata for items in the library on a regular basis + - [x] clean/vaccum task, which will clean up the database and vacuum it on a regular basis (optional, but on by default) +- [ ] scheduled-tasks: add task to get thumbnail images from video files, to be used for seeking in the UI (this needs to be optional, and off by default because the pictures will take up a lot of space... the images should be smaller, and not the full resolution of the frame) + +- [x] database: consolidate all migrations into one migration, since we've never had a release yet and no users... this will probably allow removing quite a bit of stuff which was only done temporarily and then reverted? +- [x] database: remove hacks in db.rs which were mostly used to repair migrations that already ran and then were modified after the fact, thus the updated migrations never ran +- [x] database: clearly document how the migrations work, and ensure we do not add these kind of hacks again in the future... using the migration system should be the only way to modify the database schema +- [x] database: media files should be a one-to-many relationship as they can be part of multiple libraries +- [x] database: add a table for external media, such as youtube video urls (but also from other online sources)... the theme songs/trailers would point to the external media table instead of storing the url directly in the metadata tables (allow a lot of deduplication) +- [x] database: add a type for extras (support all the types from trailerdb.org at a minimum as well as a theme song type) +- [x] database: keep track of watched items, how many times they were watched, and when they were watched last... for each user... if watched we should show a watched icon on the item and maybe a (circular?) progress bar for partially watched items + +- [ ] permissions: properly guard api endpoints... many need to be admin only, while others would be allowed for standard users... users who have not been granted access to a library should not be able to see any information about that library or the items in it from the api or ui +- [ ] permissions: ensure non admins do not have admin functions in the UI (no scanning, no metadata refresh, no user management, etc...) + +- [ ] extras: add external players for extras + - [ ] yt-dlp (https://github.com/yt-dlp/yt-dlp) + - [ ] streamlink (https://streamlink.github.io) + - [ ] vlc (https://www.videolan.org/vlc/) + - [ ] mpv (https://mpv.io/) + +- [x] metadata: prefer svg images for icons/logos, if svg not available png is okay +- [x] metadata: TVDB does not automatch very well, I think it's something with default values for the search not being correctly applied in all cases +- [x] metadata: for tv shows, show missing seasons and missing episodes in the ui with a "missing" badge or something... will require collecting metadata for all seasons and episodes of a show, not just the ones that are in the library +- [x] metadata: tmdb - add guest stars (people) for episodes +- [x] metadata: tmdb/tvdb - can metadata refresh speed be optimized, especially for tv shows? very slow right now + - maybe people collection can be done afterwards? this might avoid a lot of duplication? +- [x] web-ui/metadata: when showing X seasons badge, it should show the number of seasons we have episodes in, and not count missing seasons + +- [x] metadata-providers: decouple from metadata/mod.rs as much as possible +- [x] metadata-providers: add a template to make adding providers easier, make sure it's very well documented +- [x] metadata-providers: need to have primary and secondary providers +- [x] metadata-providers: move ThemerrDB to a secondary provider that extends tmdb and tvdb +- [x] metadata-providers: decouple Themerr from main code and only have it in its own provider module. We should drop the theme url into our database, just like all other metadata... we may need to add some YouTube url helpers as it seems many providers are using YouTube videos directly +- [x] metadata-providers: add collection support for ThemerrDB (https://github.com/LizardByte/ThemerrDB/tree/database/movie_collections)... may require adding a new entry for supported_kinds +- [x] metadata-providers: add overview for collections and other data available from tmdb +- [x] metadata-providers/database: collections have duplicate entries in the database when multiple movies are in the same collection, need to de-duplicate this and have a single entry for the collection with a many-to-many relationship to items +- [x] metadata-providers/database: themerr should not set "name" column for collections +- [x] metadata-providers: allow getting trailers from tvdb +- [x] metadata-providers: tvdb does not get biography for people +- [x] metadata-providers: add trailerdb (https://trailerdb.org/api-docs) as a secondary provider for movies and shows, it should be able to extend tmdb +- [x] metadata-providers: tmdb and tvdb... store external ids such as imdb id, and all external ids for people +- [x] metadata-providers: after tvdb has external ids, themerr can use the imdb id to get theme songs for tvdb + +- [x] settings: settings.yml file should be extremely minimal, with everything else living in the database +- [x] settings: can tmdb and tvdb api_keys (and other keys/tokens/passwords) be encrypted somehow? +- [x] settings: language for providers should not be a global setting, but should be a per library option... +- [x] settings: provider settings should be their own modal/page instead of in the main settings... each provider with their own settings +- [x] settings: creating libraries needs to be revamped + - [x] we need to have permissions for libraries, e.g. who is allowed to view them + - [x] put providers in a vertical list + - [x] allow re-ordering the provider preference + - [x] add button to provider settings + - [x] have dropdown for language, and allow multiple selections + +- [x] users: add ability for profile image upload instead of providing a url to an online hosted image + +- [x] ui/ux: Collection pages should be almost identical to a TV Show page, same for playlists and categories +- [x] ui/ux: manual linkage search has one provider selected, but it's not necessarily the library default options... should be the default providers of the current library +- [x] ui/ux: when searching live, if you type "top " (with a trailing space) and wait a second, it will remove the space, and then you cannot continue typing without adding another space... the search could do a strip, but we should leave the search text field as is +- [x] ui/ux: when searching, live or full, we should make the home preview show the highlighted search result +- [x] ui/ux: after doing a full search there is no way to return to the main library +- [x] ui/ux: live search box should have an "x" to close it out and reset the search field +- [x] ui/ux: Manual linkage search may return results in movie's native language instead of library language... it should return in library language (T-34 with TVDB) +- [x] ui/ux: Manual linkage search selected providers should only default to the current library enabled provider(s) +- [x] ui/ux: Show attribution image in manual search results instead of name, can fall back to name if no image is available +- [x] ui/ux: TMDB and TVDB providers should have a search score penalty if the casing is off (uppercase vs lowercase) +- [x] ui/ux: home/library search should be cross library for both live and full results... no matter what library is currently selected +- [x] ui/ux: home/library search should search all item types (movies, shows, seasons, episodes, collections, playlists, people, and future types) +- [x] ui/ux: Allow for more than 12 items in home/library rows, but they should lazy load + +- [x] home/library pages: for tv show libraries, recommended should not be individual seasons or episodes, just shows + +- [x] item-pages: ThemerrDB themes are not working anymore... remember when navigating between a show, season, and episode within the same show the theme song should not restart +- [x] item-pages: Very slow to load episode thumbnails when viewing a season of a show. Probably need to revamp how metadata is stored... Fetching a lot of shows from the database at once is probably slow? + - batched primary metadata-link loading for item and child lists to remove per-item metadata-link queries (N+1), which speeds season episode grids and thumbnail metadata resolution + +- [ ] tests: Move all rust tests to tests directory instead of just directly in the source file. Need to carefully expose private code to tests. I believe there are ways to do this in rust cleanly. +- [ ] tests: Add tests for web ui client... maybe use playwright? + +- [ ] features: add podcast support, users should be able to add the podcasts they are specifically interested in and it should appear like a library with shows and episodes... we could also allow users to add custom rss feeds for podcasts that are not in the providers... not 100% sure how to search for podcasts and get metadata... any free APIs for this? maybe use a provider like itunes search api or something? https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/ ... this would be a new provider module for podcasts, and we would need to add a new media type for podcast episodes... the player would also need to be revamped to support audio only playback with a static image instead of a video... we could also add support for showing the episode transcript if available, maybe even with a karaoke style highlighting of the current line as it is being spoken + - ref: https://github.com/advplyr/audiobookshelf/blob/master/server/providers/iTunes.js +- [ ] features: add local music library support using musicbrainz as a metadata provider with crate https://crates.io/crates/musicbrainz_rs... this would be similar to podcasts but with albums and tracks instead of shows and episodes... the player would also need to be revamped to support audio only playback with a static image instead of a video (no more than 1 api call per second) +- [ ] features: can we link people from tmdb/tvdb to musicbrainz people? I think tmdb has external ids for the imdb id, and so do musicbrainz, so we could use that as a common key to link them together + - actually each db has external ids, so we can store all the external ids to link tmdb people to tvdb people, or any other provider + - tmdb external ids: + - Facebook + - IMDb + - Instagram + - TicTok + - Twitter + - Wikidata + - YouTube + - tvdb external ids (not sure, but I think it's under remoteIds of extended people results) + - musicbrainz external ids (https://community.metabrainz.org/t/how-can-i-retrieve-the-list-of-external-links-annotation-and-wikipedia-text-via-api/621544/3): + - Facebook + - IMDb + - Instagram + - Twitter + - Wikidata + - YouTube + - any many many more +- [ ] features: add parental controls, with content limits based on age and rating... time limits... and reports... don't only report on what was watched, but also on items that were viewed +- [ ] features: add ability to easily rate content after watching... simple thumbs up/down... after a positive rating can show similar/related content +- [ ] features: watch together and have animated emoji reactions (https://www.npmjs.com/package/@remotion/animated-emoji) + - allow watching together with another person on a different client + - if a person reacts with an emoji, show that emoji animating on all the clients (that are part of the watch together party) in real time + +- [x] licensing: add LB license +- [ ] licensing: figure out how to monetize + - https://polar.sh/ + - https://dodopayments.com/blogs/polar-alternatives-saas-billing diff --git a/packaging/linux/flatpak/README.md b/packaging/linux/flatpak/README.md new file mode 100644 index 00000000..e78cc3f2 --- /dev/null +++ b/packaging/linux/flatpak/README.md @@ -0,0 +1,6 @@ +# Koko Flatpak + +This directory contains the Flatpak manifest and desktop metadata for Koko. + +The CI workflow generates npm and Cargo source manifests before invoking +`flatpak-builder`, so the build runs without network access inside the sandbox. diff --git a/packaging/linux/flatpak/deps/flatpak-builder-tools b/packaging/linux/flatpak/deps/flatpak-builder-tools new file mode 160000 index 00000000..737c0085 --- /dev/null +++ b/packaging/linux/flatpak/deps/flatpak-builder-tools @@ -0,0 +1 @@ +Subproject commit 737c0085912f9f7dabf9341d4608e2a77a51a73a diff --git a/packaging/linux/flatpak/deps/shared-modules b/packaging/linux/flatpak/deps/shared-modules new file mode 160000 index 00000000..75fa45bd --- /dev/null +++ b/packaging/linux/flatpak/deps/shared-modules @@ -0,0 +1 @@ +Subproject commit 75fa45bdee634e6b3ad36e1db33a69c1ba43417c diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop new file mode 100644 index 00000000..b402fa3f --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Categories=AudioVideo;Network; +Comment=Self-hosted media server +Exec=koko +Icon=dev.lizardbyte.app.Koko +Keywords=media;server;movies;tv;music;photos; +Name=Koko +Terminal=false +Type=Application +Version=1.0 diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml new file mode 100644 index 00000000..34632f31 --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml @@ -0,0 +1,28 @@ + + + dev.lizardbyte.app.Koko + Koko + Self-hosted media server + CC0-1.0 + LicenseRef-LizardByte-Source-Available + + LizardByte + + +

+ Koko is a self-hosted media server for managing and streaming personal media libraries. +

+
+ + + https://github.com/LizardByte/Koko/releases/tag/v@BUILD_VERSION@ + +

See the changelog on GitHub

+
+
+
+ dev.lizardbyte.app.Koko.desktop + https://app.lizardbyte.dev + https://app.lizardbyte.dev/support + +
diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml new file mode 100644 index 00000000..e4ea2fdc --- /dev/null +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Koko.yml @@ -0,0 +1,86 @@ +--- +app-id: dev.lizardbyte.app.Koko +runtime: org.freedesktop.Platform +runtime-version: "25.08" +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable +add-build-extensions: + org.freedesktop.Sdk.Extension.node24: + directory: lib/sdk/node24 + version: "25.08" + no-autodownload: true + autodelete: false +command: koko +separate-locales: false + +finish-args: + - --filesystem=home + - --filesystem=host:ro + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --talk-name=org.freedesktop.secrets + - --talk-name=org.kde.StatusNotifierWatcher + +cleanup: + - /include + - /lib/cmake + - /lib/pkgconfig + - /lib/*.la + - /lib/*.a + - /share/man + +modules: + - shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json + # Required by the Linux tray/menu stack through the muda crate. + - modules/libxdo.json + + - name: koko + buildsystem: simple + build-options: + append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/node24/bin + env: + BUILD_VERSION: "@BUILD_VERSION@" + CARGO_HOME: /run/build/koko/cargo + CARGO_NET_OFFLINE: "true" + LD_LIBRARY_PATH: /app/lib + LIBRARY_PATH: /app/lib + npm_config_cache: /run/build/koko/flatpak-node/npm-cache + npm_config_nodedir: /usr/lib/sdk/node24 + npm_config_offline: "true" + NPM_CONFIG_LOGLEVEL: info + PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig + XDG_CACHE_HOME: /run/build/koko/flatpak-node/cache + build-commands: + - install -Dm0644 cargo/config .cargo/config.toml + - cd crates/client-web && npm ci --offline --ignore-scripts && npm run build + - cargo build --offline --locked --release + - install -Dm0755 target/release/koko /app/libexec/koko/koko + - install -Dm0755 packaging/linux/flatpak/scripts/koko.sh /app/bin/koko + - install -dm0755 /app/share/koko + - cp -a assets /app/share/koko/assets + - cp -a crates/client-web/dist /app/share/koko/client-web + - install -Dm0644 assets/Koko.svg /app/share/icons/hicolor/scalable/apps/dev.lizardbyte.app.Koko.svg + - >- + install -Dm0644 + packaging/linux/flatpak/dev.lizardbyte.app.Koko.desktop + /app/share/applications/dev.lizardbyte.app.Koko.desktop + - >- + install -Dm0644 + packaging/linux/flatpak/dev.lizardbyte.app.Koko.metainfo.xml + /app/share/metainfo/dev.lizardbyte.app.Koko.metainfo.xml + run-tests: true + test-rule: "" + test-commands: + - cargo test --offline --locked --workspace --lib --bins --release + sources: + - type: git + url: "@GITHUB_CLONE_URL@" + commit: "@GITHUB_COMMIT@" + - type: file + path: dev.lizardbyte.app.Koko.metainfo.xml + dest: packaging/linux/flatpak + - generated-node-sources.json + - generated-cargo-sources.json diff --git a/packaging/linux/flatpak/exceptions.json b/packaging/linux/flatpak/exceptions.json new file mode 100644 index 00000000..9a537de7 --- /dev/null +++ b/packaging/linux/flatpak/exceptions.json @@ -0,0 +1,9 @@ +{ + "dev.lizardbyte.app.Koko": [ + "appid-url-not-reachable", + "appstream-screenshots-not-mirrored-in-ostree", + "metainfo-missing-screenshots", + "finish-args-host-ro-filesystem-access", + "finish-args-home-filesystem-access" + ] +} diff --git a/packaging/linux/flatpak/flathub.json b/packaging/linux/flatpak/flathub.json new file mode 100644 index 00000000..2de28147 --- /dev/null +++ b/packaging/linux/flatpak/flathub.json @@ -0,0 +1,3 @@ +{ + "disable-external-data-checker": true +} diff --git a/packaging/linux/flatpak/modules/libxdo.json b/packaging/linux/flatpak/modules/libxdo.json new file mode 100644 index 00000000..012a59e7 --- /dev/null +++ b/packaging/linux/flatpak/modules/libxdo.json @@ -0,0 +1,23 @@ +{ + "name": "libxdo", + "buildsystem": "simple", + "build-commands": [ + "make libxdo.so libxdo.so.4 libxdo.pc PREFIX=\"${FLATPAK_DEST}\" WITHOUT_RPATH_FIX=1", + "install -Dm0755 libxdo.so \"${FLATPAK_DEST}/lib/libxdo.so.4\"", + "ln -sf libxdo.so.4 \"${FLATPAK_DEST}/lib/libxdo.so\"", + "install -Dm0644 xdo.h \"${FLATPAK_DEST}/include/xdo.h\"", + "install -Dm0644 libxdo.pc \"${FLATPAK_DEST}/lib/pkgconfig/libxdo.pc\"" + ], + "cleanup": [ + "/include", + "/lib/pkgconfig" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/jordansissel/xdotool.git", + "tag": "v4.20260303.1", + "commit": "d37c16111dbe38ea29194f20ac9de03cff8dfc1c" + } + ] +} diff --git a/packaging/linux/flatpak/scripts/koko.sh b/packaging/linux/flatpak/scripts/koko.sh new file mode 100644 index 00000000..402573c6 --- /dev/null +++ b/packaging/linux/flatpak/scripts/koko.sh @@ -0,0 +1,4 @@ +#!/bin/sh +export KOKO_ASSETS_DIR="${KOKO_ASSETS_DIR:-/app/share/koko/assets}" +export KOKO_WEB_CLIENT_DIST="${KOKO_WEB_CLIENT_DIST:-/app/share/koko/client-web}" +exec /app/libexec/koko/koko "$@" diff --git a/packaging/macos/package-dmg.sh b/packaging/macos/package-dmg.sh new file mode 100644 index 00000000..d755728d --- /dev/null +++ b/packaging/macos/package-dmg.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +app_name="Koko" +bundle_id="dev.lizardbyte.app.Koko" +target="" +version="" +binary_path="" +output_dir="artifacts" +work_dir="target/macos-package" +sign_bundle="false" +codesign_identity="${APPLE_CODESIGN_IDENTITY:-}" + +function usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "${target}" ]]; then + target="$(rustc -vV | sed -n 's/^host: //p')" +fi + +if [[ -z "${version}" ]]; then + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" +fi + +if [[ -z "${binary_path}" ]]; then + binary_path="target/${target}/release/koko" +fi + +if [[ -z "${target}" || -z "${version}" ]]; then + echo "Both --target and --version must resolve to non-empty values." >&2 + exit 1 +fi + +if [[ ! -f "${binary_path}" ]]; then + echo "Koko binary not found: ${binary_path}" >&2 + exit 1 +fi +chmod +x "${binary_path}" + +if [[ ! -f "crates/client-web/dist/index.html" ]]; then + echo "Web client bundle not found. Run npm run build in crates/client-web first." >&2 + exit 1 +fi + +if [[ "${sign_bundle}" == "true" && -z "${codesign_identity}" ]]; then + echo "APPLE_CODESIGN_IDENTITY must be set when --sign is used." >&2 + exit 1 +fi + +bundle_version="$( + printf '%s' "${version}" \ + | sed -E 's/^[vV]//; s/[^0-9.]/./g; s/\.+/./g; s/^\.//; s/\.$//' +)" +if [[ -z "${bundle_version}" ]]; then + bundle_version="0" +fi + +minimum_system_version="${MACOSX_DEPLOYMENT_TARGET:-}" +if [[ -z "${minimum_system_version}" ]]; then + echo "MACOSX_DEPLOYMENT_TARGET must be set to write LSMinimumSystemVersion." >&2 + exit 1 +fi + +package_dir="${work_dir}/${target}" +app_dir="${package_dir}/${app_name}.app" +contents_dir="${app_dir}/Contents" +macos_dir="${contents_dir}/MacOS" +resources_dir="${contents_dir}/Resources" +dmg_root="${package_dir}/dmg-root" +dmg_path="${output_dir}/koko-${target}.dmg" + +rm -rf "${package_dir}" +mkdir -p "${macos_dir}" "${resources_dir}" "${dmg_root}" "${output_dir}" + +install -m 0755 "${binary_path}" "${macos_dir}/koko" +ditto "assets" "${resources_dir}/assets" +ditto "crates/client-web/dist" "${resources_dir}/client-web/dist" +install -m 0644 "LICENSE" "${resources_dir}/LICENSE" + +iconset_dir="${package_dir}/${app_name}.iconset" +mkdir -p "${iconset_dir}" +for size in 16 32 128 256 512; do + sips -z "${size}" "${size}" "assets/Koko.png" \ + --out "${iconset_dir}/icon_${size}x${size}.png" >/dev/null + + retina_size=$((size * 2)) + if [[ "${retina_size}" -le 512 ]]; then + sips -z "${retina_size}" "${retina_size}" "assets/Koko.png" \ + --out "${iconset_dir}/icon_${size}x${size}@2x.png" >/dev/null + fi +done +iconutil -c icns "${iconset_dir}" -o "${resources_dir}/${app_name}.icns" + +cat > "${contents_dir}/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${app_name} + CFBundleExecutable + koko + CFBundleIconFile + ${app_name}.icns + CFBundleIdentifier + ${bundle_id} + CFBundleName + ${app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${bundle_version} + CFBundleVersion + ${bundle_version} + LSApplicationCategoryType + public.app-category.entertainment + LSMinimumSystemVersion + ${minimum_system_version} + LSUIElement + + NSLocalNetworkUsageDescription + ${app_name} serves your media library on your local network. + + +EOF +plutil -lint "${contents_dir}/Info.plist" + +if [[ "${sign_bundle}" == "true" ]]; then + xattr -rc "${app_dir}" + codesign --force --timestamp --options runtime \ + --sign "${codesign_identity}" \ + "${macos_dir}/koko" + codesign --force --timestamp --options runtime \ + --sign "${codesign_identity}" \ + "${app_dir}" + codesign --verify --deep --strict --verbose=2 "${app_dir}" +fi + +ditto "${app_dir}" "${dmg_root}/${app_name}.app" +ln -s /Applications "${dmg_root}/Applications" + +rm -f "${dmg_path}" +if ! hdiutil create -volname "${app_name}" -srcfolder "${dmg_root}" -ov -format UDZO "${dmg_path}"; then + echo "hdiutil failed, retrying once..." >&2 + sleep 5 + hdiutil create -volname "${app_name}" -srcfolder "${dmg_root}" -ov -format UDZO "${dmg_path}" +fi + +if [[ "${sign_bundle}" == "true" ]]; then + codesign --force --timestamp \ + --sign "${codesign_identity}" \ + "${dmg_path}" + codesign --verify --verbose=2 "${dmg_path}" +fi + +echo "Created ${dmg_path}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..45bb7533 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "Koko" +version = "0.0.0" +description = "Python tooling for Koko" +requires-python = ">=3.14" +license = {text = "LicenseRef-LizardByte-Source-Available"} +authors = [ + {name = "LizardByte", email = "lizardbyte@users.noreply.github.com"} +] + +dependencies = [] + +[dependency-groups] +flatpak = [ + "aiohttp==3.14.1", + "flatpak_node_generator==0.1.0", + "pyyaml==6.0.3", + "tomlkit==0.15.0", +] + +[project.urls] +Homepage = "https://app.lizardbyte.dev" +Repository = "https://github.com/LizardByte/Koko" +Issues = "https://github.com/LizardByte/Koko/issues" + +[tool.setuptools] +py-modules = [] + +[tool.uv.sources] +flatpak_node_generator = { path = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" } diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..df569804 --- /dev/null +++ b/uv.lock @@ -0,0 +1,350 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "flatpak-node-generator" +version = "0.1.0" +source = { directory = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" } +dependencies = [ + { name = "aiohttp" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0,<4.0.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "koko" +version = "0.0.0" +source = { editable = "." } + +[package.dev-dependencies] +flatpak = [ + { name = "aiohttp" }, + { name = "flatpak-node-generator" }, + { name = "pyyaml" }, + { name = "tomlkit" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +flatpak = [ + { name = "aiohttp", specifier = "==3.14.1" }, + { name = "flatpak-node-generator", directory = "packaging/linux/flatpak/deps/flatpak-builder-tools/node" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "tomlkit", specifier = "==0.15.0" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..40119526 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "xtask" +version.workspace = true +authors.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..3fbba2d8 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,385 @@ +use std::{ + collections::{ + HashSet, + hash_map::DefaultHasher, + }, + env, + error::Error, + fs, + hash::{ + Hash, + Hasher, + }, + io::{ + self, + Write, + }, + path::{ + Path, + PathBuf, + }, + process::Command, + time::{ + SystemTime, + UNIX_EPOCH, + }, +}; + +const MIGRATIONS_DIR: &str = "crates/server/sql/migrations"; +const DB_MODULE: &str = "crates/server/src/db/mod.rs"; +const ORDER_MARKER: &str = "const SQLITE_MIGRATION_ORDER: &[&str] = &["; + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let mut args = env::args().skip(1).collect::>(); + let Some(command) = args.first().map(String::as_str) else { + print_usage(); + return Ok(()); + }; + + match command { + "new-migration" => { + args.remove(0); + new_migration(args) + } + "-h" | "--help" | "help" => { + print_usage(); + Ok(()) + } + other => Err(format!("unknown xtask command `{other}`").into()), + } +} + +fn new_migration(args: Vec) -> Result<(), Box> { + let mut name = None; + let mut requested_version = None; + let mut migrations_dir_arg = None; + let mut db_module_arg = None; + let mut no_fmt = false; + + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => { + print_new_migration_usage(); + return Ok(()); + } + "--no-fmt" => no_fmt = true, + "--version" => { + requested_version = Some( + args.next() + .ok_or("missing value after --version")? + .trim() + .to_owned(), + ); + } + "--migrations-dir" => { + migrations_dir_arg = Some( + args.next() + .ok_or("missing value after --migrations-dir")? + .trim() + .to_owned(), + ); + } + "--db-module" => { + db_module_arg = Some( + args.next() + .ok_or("missing value after --db-module")? + .trim() + .to_owned(), + ); + } + _ if arg.starts_with("--version=") => { + requested_version = Some(arg["--version=".len()..].trim().to_owned()); + } + _ if arg.starts_with("--migrations-dir=") => { + migrations_dir_arg = Some(arg["--migrations-dir=".len()..].trim().to_owned()); + } + _ if arg.starts_with("--db-module=") => { + db_module_arg = Some(arg["--db-module=".len()..].trim().to_owned()); + } + _ if arg.starts_with('-') => return Err(format!("unknown option `{arg}`").into()), + _ if name.is_none() => name = Some(arg), + _ => return Err(format!("unexpected extra argument `{arg}`").into()), + } + } + + let name = match name { + Some(name) => name, + None => prompt("Migration name")?, + }; + let slug = migration_slug(&name)?; + + let repo_root = repo_root()?; + let migrations_dir = repo_path( + &repo_root, + migrations_dir_arg.as_deref().unwrap_or(MIGRATIONS_DIR), + ); + let db_module = repo_path(&repo_root, db_module_arg.as_deref().unwrap_or(DB_MODULE)); + + let mut existing_versions = existing_migration_versions(&migrations_dir)?; + existing_versions.extend(ordered_migration_versions(&db_module)?); + + let version = match requested_version { + Some(version) => validate_requested_version(version, &existing_versions)?, + None => generate_version(&existing_versions)?, + }; + + let migration_name = format!("{version}_{slug}"); + let migration_dir = migrations_dir.join(&migration_name); + if migration_dir.exists() { + return Err(format!( + "migration directory already exists: {}", + migration_dir.display() + ) + .into()); + } + + fs::create_dir_all(&migration_dir)?; + fs::write(migration_dir.join("up.sql"), "-- Add migration SQL here.\n")?; + fs::write( + migration_dir.join("down.sql"), + "-- Revert migration SQL here.\n", + )?; + update_migration_order(&db_module, &version)?; + + if !no_fmt { + run_rustfmt(&repo_root)?; + } + + println!("Migration: {migration_name}"); + println!("Path: {}", migration_dir.display()); + println!("Revision: {version}"); + + Ok(()) +} + +fn repo_root() -> Result> { + let current_dir = env::current_dir()?; + if current_dir.join("Cargo.toml").exists() && current_dir.join("crates/server").exists() { + return Ok(current_dir); + } + + let mut dir = current_dir.as_path(); + while let Some(parent) = dir.parent() { + if parent.join("Cargo.toml").exists() && parent.join("crates/server").exists() { + return Ok(parent.to_owned()); + } + dir = parent; + } + + Err("could not find Koko repository root".into()) +} + +fn prompt(label: &str) -> Result> { + print!("{label}: "); + io::stdout().flush()?; + + let mut value = String::new(); + io::stdin().read_line(&mut value)?; + + let value = value.trim().to_owned(); + if value.is_empty() { + return Err(format!("{label} cannot be empty").into()); + } + + Ok(value) +} + +fn repo_path( + repo_root: &Path, + path: &str, +) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { path } else { repo_root.join(path) } +} + +fn migration_slug(name: &str) -> Result> { + let mut slug = String::new(); + let mut last_was_separator = true; + + for character in name.trim().chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + slug.push(character); + last_was_separator = false; + } else if !last_was_separator { + slug.push('_'); + last_was_separator = true; + } + } + + while slug.ends_with('_') { + slug.pop(); + } + + if slug.is_empty() { + return Err("migration name must contain at least one ASCII letter or number".into()); + } + + Ok(slug) +} + +fn existing_migration_versions(migrations_dir: &Path) -> Result, Box> { + let mut versions = HashSet::new(); + if !migrations_dir.exists() { + return Ok(versions); + } + + for entry in fs::read_dir(migrations_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some((version, _)) = name.split_once('_') { + versions.insert(version.to_owned()); + } + } + + Ok(versions) +} + +fn ordered_migration_versions(db_module: &Path) -> Result, Box> { + Ok(read_migration_order(db_module)?.into_iter().collect()) +} + +fn read_migration_order(db_module: &Path) -> Result, Box> { + let content = fs::read_to_string(db_module)?; + let start = content + .find(ORDER_MARKER) + .ok_or("could not find SQLITE_MIGRATION_ORDER")?; + let body_start = start + ORDER_MARKER.len(); + let end = content[body_start..] + .find("];") + .map(|offset| body_start + offset) + .ok_or("could not find the end of SQLITE_MIGRATION_ORDER")?; + + Ok(quoted_strings(&content[body_start..end])) +} + +fn quoted_strings(input: &str) -> Vec { + let mut values = Vec::new(); + let mut rest = input; + + while let Some(start) = rest.find('"') { + let after_start = &rest[start + 1..]; + let Some(end) = after_start.find('"') else { + break; + }; + values.push(after_start[..end].to_owned()); + rest = &after_start[end + 1..]; + } + + values +} + +fn validate_requested_version( + version: String, + existing_versions: &HashSet, +) -> Result> { + let version = version.trim().to_ascii_lowercase(); + if version.is_empty() { + return Err("migration revision cannot be empty".into()); + } + if !version + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("migration revision must contain only hexadecimal characters".into()); + } + if existing_versions.contains(&version) { + return Err(format!("migration revision {version} already exists").into()); + } + + Ok(version) +} + +fn generate_version(existing_versions: &HashSet) -> Result> { + for attempt in 0_u64..1024 { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let mut hasher = DefaultHasher::new(); + now.hash(&mut hasher); + std::process::id().hash(&mut hasher); + attempt.hash(&mut hasher); + + let version = format!("{:012x}", hasher.finish() & 0x0000_ffff_ffff_ffff); + if !existing_versions.contains(&version) { + return Ok(version); + } + } + + Err("failed to generate a unique migration revision after 1024 attempts".into()) +} + +fn update_migration_order( + db_module: &Path, + version: &str, +) -> Result<(), Box> { + let content = fs::read_to_string(db_module)?; + let start = content + .find(ORDER_MARKER) + .ok_or("could not find SQLITE_MIGRATION_ORDER")?; + let body_start = start + ORDER_MARKER.len(); + let end = content[body_start..] + .find("];") + .map(|offset| body_start + offset) + .ok_or("could not find the end of SQLITE_MIGRATION_ORDER")?; + + let mut versions = quoted_strings(&content[body_start..end]); + if versions.iter().any(|existing| existing == version) { + return Err(format!("migration revision {version} is already listed").into()); + } + versions.push(version.to_owned()); + + let body = versions + .iter() + .map(|version| format!(" \"{version}\",")) + .collect::>() + .join("\n"); + let replacement = format!("{ORDER_MARKER}\n{body}\n];"); + + let updated = format!( + "{}{}{}", + &content[..start], + replacement, + &content[end + 2..] + ); + fs::write(db_module, updated)?; + + Ok(()) +} + +fn run_rustfmt(repo_root: &Path) -> Result<(), Box> { + let status = Command::new("cargo") + .arg("+nightly") + .arg("fmt") + .current_dir(repo_root) + .status()?; + + if !status.success() { + return Err("cargo +nightly fmt failed".into()); + } + + Ok(()) +} + +fn print_usage() { + println!("Usage:"); + println!(" cargo new-migration [name] [--version ] [--no-fmt]"); +} + +fn print_new_migration_usage() { + println!("Usage:"); + println!(" cargo new-migration [name] [--version ] [--no-fmt]"); + println!(); + println!("Examples:"); + println!(" cargo new-migration add_media_flags"); + println!(" cargo new-migration add_media_flags --version facefeed1234"); +}